前盖内

inside front cover

测试食谱

Test recipes

国际金融公司



测试配方是一个测试计划,概述了应在哪个级别测试特定功能。

A test recipe is a test plan, outlining at which level a particular feature should be tested.

对第二版的赞扬

Praise for the second edition

这本书很特别。这些章节相互借鉴,积累了惊人的深度。准备好享受美食吧。

This book is something special. The chapters build on each other to a startling accumulation of depth. Get ready for a treat.

——来自 Robert C. Martin 第二版的前言,cleancoder.com

—From the foreword of the second edition by Robert C. Martin, cleancoder.com

从现在该领域的经典中学习单元测试的最佳方法。

The best way to learn unit testing from what is now a classic in the field.

—Raphael Faria,LG 电子公司

—Raphael Faria, LG Electronics

教您有效单元测试的理念以及具体细节。

Teaches you the philosophy as well as the nuts and bolts for effective unit testing.

—Pradeep Chellappan,微软

—Pradeep Chellappan, Microsoft

当我的团队成员问我如何以正确的方式编写单元测试时,我简单地回答:买这本书!

When my team members ask me how to write unit tests the right way, I simply answer: Get this book!

—Alessandro Campeis,Vimar SpA

—Alessandro Campeis, Vimar SpA

有关单元测试的最佳资源。

The single best resource on unit testing.

—Kaleb Pederson,Next IT 公司

—Kaleb Pederson, Next IT Corporation

我读过的最有用和最新的单元测试指南。

The most useful and up-to-date guide to unit testing I have ever read.

——弗朗西斯科·戈吉,菲亚特

—Francesco Goggi, FIAT

对于任何希望学习或完善其单元测试知识的认真的 .NET 开发人员来说,这是必须的。

A must for any serious .NET developer wishing to learn or perfect their unit testing knowledge.

—Karl Metivier,加鼎证券金融公司

—Karl Metivier, Desjardins Security Financial

  

  

 

 

 

 

单元测试的艺术

The Art of Unit Testing

第三版

Third Edition

以及 JavaScript 中的示例

with examples in JavaScript

 

 

罗伊·奥谢罗夫和弗拉基米尔·霍里科夫

Roy Osherove with Vladimir Khorikov

 

 

 

 

 

 

评论请前往liveBook

To comment go to liveBook

 

 

 

 

 

 

有关此及其他曼宁头衔的更多信息,请访问

For more information on this and other Manning titles go to

www.manning.com

www.manning.com

 

 

版权

Copyright

有关这些及其他曼宁书籍的在线信息和订购,请访问www.manning.com。出版商在订购这些书籍时提供折扣。

For online information and ordering of these  and other Manning books, please visit www.manning.com. The publisher offers discounts on these books when ordered in quantity.

获取更多资讯,请联系

For more information, please contact

  

  

特约销售部

Special Sales Department

曼宁出版公司

Manning Publications Co.

鲍德温路20号

20 Baldwin Road

邮政信箱 761

PO Box 761

谢尔特岛, 纽约 11964

Shelter Island, NY 11964

电子邮件:orders@manning.com

Email: orders@manning.com

  

  

  

  

未经出版商事先书面许可,不得以任何形式或通过电子、机械、影印或其他方式复制、存储或传播本出版物的任何部分。

No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form or by means electronic, mechanical, photocopying, or otherwise, without prior written permission of the publisher.

制造商和销售商用来区分其产品的许多名称都被称为商标。如果这些名称出现在书中,并且曼宁出版公司知道商标声明,则这些名称均以首字母大写或全部大写印刷。

Many of the designations used by manufacturers and sellers to distinguish their products are claimed as trademarks. Where those designations appear in the book, and Manning Publications was aware of a trademark claim, the designations have been printed in initial caps or all caps.

认识到保存所写内容的重要性,曼宁的政策是使用无酸纸印刷我们出版的书籍,并且我们为此尽了最大努力。曼宁还认识到我们有责任保护地球资源,因此印刷的纸张至少有 15% 是回收的,并且在加工过程中不使用元素氯。

Recognizing the importance of preserving what has been written, it is Manning’s policy to have the books we publish printed on acid-free paper, and we exert our best efforts to that end. Recognizing also our responsibility to conserve the resources of our planet, Manning books are printed on paper that is at least 15 percent recycled and processed without the use of elemental chlorine.

 

 

    

    

曼宁出版公司

Manning Publications Co.

20 鲍德温路技术

20 Baldwin Road Technical

邮政信箱 761

PO Box 761

谢尔特岛, 纽约 11964

Shelter Island, NY 11964

  

  

开发编辑:  

Development editor:  

康纳·奥布莱恩

Connor O’Brien

技术开发编辑:  

Technical development editor:  

迈克·谢泼德

Mike Shepard

审稿编辑:  

Review editors:  

阿德里亚娜·萨博和邓贾·尼基托维奇

Adriana Sabo and Dunja Nikitović

制作编辑:  

Production editor:  

凯西·罗斯兰

Kathy Rossland

复制编辑器:  

Copy editor:  

安迪·卡罗尔

Andy Carroll

校对:  

Proofreader:  

凯蒂·田纳特

Katie Tennant

技术校对员:  

Technical proofreader:  

让-弗朗索瓦·莫兰

Jean-François Morin

排字机:  

Typesetter:  

丹尼斯·达林尼克

Dennis Dalinnik

封面设计师:  

Cover designer:  

玛丽亚·都铎

Marija Tudor

  

  

  

  

国际书号:9781617297489

ISBN: 9781617297489

奉献

dedication

致塔尔、伊塔玛、阿维夫和伊多。我的家人。

To Tal, Itamar, Aviv, and Ido. My family.

——罗伊·奥谢罗夫

—Roy Osherove

致我的妻子尼娜和儿子蒂莫西。

To my wife Nina and son Timothy.

——弗拉基米尔·霍里科夫

—Vladimir Khorikov

内容

contents

前言

Front matter

第二版前言

foreword to the second edition

第一版前言

foreword to the first edition

前言

preface

致谢

acknowledgments

关于这本书

about this book

关于作者

about the authors

关于封面插画

about the cover illustration

第 1 部分 入门

Part 1 Getting started

1 单元测试的基础知识

1 The basics of unit testing

1.1 第一步

1.1 The first step

1.2 逐步定义单元测试

1.2 Defining unit testing, step by step

1.3 入口点和出口点

1.3 Entry points and exit points

1.4 退出点类型

1.4 Exit point types

1.5 不同的退出点,不同的技术

1.5 Different exit points, different techniques

1.6 从头开始​​测试

1.6 A test from scratch

1.7 良好的单元测试的特征

1.7 Characteristics of a good unit test

什么是好的单元测试?

What is a good unit test?

单元测试清单

A unit test checklist

1.8 集成测试

1.8 Integration tests

1.9 最终确定我们的定义

1.9 Finalizing our definition

1.10 测试驱动开发

1.10 Test-driven development

TDD:不能替代良好的单元测试

TDD: Not a substitute for good unit tests

成功 TDD 所需的三项核心技能

Three core skills needed for successful TDD

2 第一个单元测试

2 A first unit test

2.1 笑话介绍

2.1 Introducing Jest

准备我们的环境

Preparing our environment

准备我们的工作文件夹

Preparing our working folder

安装笑话

Installing Jest

创建测试文件

Creating a test file

执行笑话

Executing Jest

2.2 库、断言、运行器和报告器

2.2 The library, the assert, the runner, and the reporter

2.3 单元测试框架提供什么

2.3 What unit testing frameworks offer

xUnit 框架

The xUnit frameworks

xUnit、TAP 和 Jest 结构

xUnit, TAP, and Jest structures

2.4 密码验证器项目介绍

2.4 Introducing the Password Verifier project

2.5 verifyPassword 的第一个 Jest 测试

2.5 The first Jest test for verifyPassword

安排-执行-断言模式

The Arrange-Act-Assert pattern

测试测试

Testing the test

使用命名

USE naming

字符串比较和可维护性

String comparisons and maintainability

使用描述()

Using describe()

暗示上下文的结构

Structure implying context

it() 函数

The it() function

两种 Jest 口味

Two Jest flavors

重构生产代码

Refactoring the production code

2.6 尝试 beforeEach() 路线

2.6 Trying the beforeEach() route

beforeEach() 和滚动疲劳

beforeEach() and scroll fatigue

2.7 尝试工厂方法路线

2.7 Trying the factory method route

用工厂方法完全替换 beforeEach()

Replacing beforeEach() completely with factory methods

2.8 绕一圈到 test()

2.8 Going full circle to test()

2.9 重构参数化测试

2.9 Refactoring to parameterized tests

2.10 检查预期抛出的错误

2.10 Checking for expected thrown errors

2.11 设置测试类别

2.11 Setting test categories

第二部分 核心技术

Part 2 Core techniques

3 使用桩打破依赖关系

3 Breaking dependencies with stubs

3.1 依赖类型

3.1 Types of dependencies

3.2 使用桩的原因

3.2 Reasons to use stubs

3.3 普遍接受的桩设计方法

3.3 Generally accepted design approaches to stubbing

通过参数注入消除时间

Stubbing out time with parameter injection

依赖、注入和控制

Dependencies, injections, and control

3.4 功能注入技术

3.4 Functional injection techniques

注入函数

Injecting a function

通过部分应用程序进行依赖注入

Dependency injection via partial application

3.5 模块化注入技术

3.5 Modular injection techniques

3.6 转向具有构造函数的对象

3.6 Moving toward objects with constructor functions

3.7 面向对象注入技术

3.7 Object-oriented injection techniques

构造函数注入

Constructor injection

注入对象而不是函数

Injecting an object instead of a function

提取通用接口

Extracting a common interface

4 使用模拟对象进行交互测试

4 Interaction testing using mock objects

4.1 交互测试、模拟和桩

4.1 Interaction testing, mocks, and stubs

4.2 取决于记录器

4.2 Depending on a logger

4.3 标准风格:引入参数重构

4.3 Standard style: Introduce parameter refactoring

4.4 区分模拟和桩的重要性

4.4 The importance of differentiating between mocks and stubs

4.5 模块化风格的模拟

4.5 Modular-style mocks

生产代码示例

Example of production code

以模块化注入方式重构生产代码

Refactoring the production code in a modular injection style

模块化注入的测试示例

A test example with modular-style injection

4.6 函数式风格的模拟

4.6 Mocks in a functional style

使用柯里化风格

Working with a currying style

使用高阶函数而不是柯里化

Working with higher-order functions and not currying

4.7 面向对象风格的模拟

4.7 Mocks in an object-oriented style

重构注入的生产代码

Refactoring production code for injection

通过接口注入重构生产代码

Refactoring production code with interface injection

4.8 处理复杂的接口

4.8 Dealing with complicated interfaces

复杂接口示例

Example of a complicated interface

编写具有复杂接口的测试

Writing tests with complicated interfaces

直接使用复杂接口的缺点

Downsides of using complicated interfaces directly

接口隔离原则

The interface segregation principle

4.9 部分模拟

4.9 Partial mocks

部分模拟的功能示例

A functional example of a partial mock

面向对象的部分模拟示例

An object-oriented partial mock example

5 隔离框架

5 Isolation frameworks

5.1 定义隔离框架

5.1 Defining isolation frameworks

选择口味:散装与打字

Choosing a flavor: Loose vs. typed

5.2 动态伪造模块

5.2 Faking modules dynamically

关于 Jest API 需要注意的一些事项

Some things to notice about Jest’s API

考虑抽象掉直接依赖关系

Consider abstracting away direct dependencies

5.3 函数式动态模拟和桩

5.3 Functional dynamic mocks and stubs

5.4 面向对象的动态模拟和桩

5.4 Object-oriented dynamic mocks and stubs

使用松散类型的框架

Using a loosely typed framework

切换到类型友好的框架

Switching to a type-friendly framework

5.5 动态桩行为

5.5 Stubbing behavior dynamically

带有模拟和桩的面向对象示例

An object-oriented example with a mock and a stub

使用 Replacement.js 进行桩和模拟

Stubs and mocks with substitute.js

5.6 隔离框架的优点和陷阱

5.6 Advantages and traps of isolation frameworks

大多数时候你不需要模拟对象

You don’t need mock objects most of the time

测试代码不可读

Unreadable test code

验证错误的事情

Verifying the wrong things

每个测试有多个模拟

Having more than one mock per test

过度指定测试

Overspecifying the tests

6 单元测试异步代码

6 Unit testing asynchronous code

6.1 处理异步数据获取

6.1 Dealing with async data fetching

集成测试的初步尝试

An initial attempt with an integration test

等待行动

Waiting for the act

async/await 的集成测试

Integration testing of async/await

集成测试的挑战

Challenges with integration tests

6.2 使我们的代码易于单元测试

6.2 Making our code unit-test friendly

提取入口点

Extracting an entry point

提取适配器模式

The Extract Adapter pattern

6.3 处理定时器

6.3 Dealing with timers

通过猴子补丁消除计时器

Stubbing timers out with monkey-patching

用 Jest 伪造 setTimeout

Faking setTimeout with Jest

6.4 处理常见事件

6.4 Dealing with common events

处理事件发射器

Dealing with event emitters

处理点击事件

Dealing with click events

6.5 引入DOM测试库

6.5 Bringing in the DOM testing library

第三部分 测试代码

Part 3 The test code

7 项值得信赖的测试

7 Trustworthy tests

7.1 如何知道您信任某个测试

7.1 How to know you trust a test

7.2 为什么测试失败

7.2 Why tests fail

生产代码中发现了一个真正的错误

A real bug has been uncovered in the production code

有缺陷的测试给出错误的失败

A buggy test gives a false failure

由于功能更改,该测试已过时

The test is out of date due to a change in functionality

该测试与另一个测试冲突

The test conflicts with another test

测试是片状的

The test is flaky

7.3 避免单元测试中的逻辑

7.3 Avoiding logic in unit tests

断言中的逻辑:创建动态期望值

Logic in asserts: Creating dynamic expected values

其他形式的逻辑

Other forms of logic

更有逻辑性

Even more logic

7.4 在通过测试时嗅到错误的信任感

7.4 Smelling a false sense of trust in passing tests

不断言任何内容的测试

Tests that don’t assert anything

不理解测试

Not understanding the tests

混合单元测试和片状集成测试

Mixing unit tests and flaky integration tests

测试多个出口点

Testing multiple exit points

不断变化的测试

Tests that keep changing

7.5 处理片状测试

7.5 Dealing with flaky tests

一旦发现不稳定的测试,你能做什么?

What can you do once you’ve found a flaky test?

防止更高级别测试中的不稳定

Preventing flakiness in higher-level tests

8 可维护性

8 Maintainability

8.1 由于测试失败而强制进行的更改

8.1 Changes forced by failing tests

该测试与其他测试不相关或冲突

The test is not relevant or conflicts with another test

生产代码 API 的更改

Changes in the production code’s API

其他测试的变化

Changes in other tests

8.2 重构以提高可维护性

8.2 Refactoring to increase maintainability

避免测试私有或受保护的方法

Avoid testing private or protected methods

保持测试干燥

Keep tests DRY

避免设置方法

Avoid setup methods

使用参数化测试来消除重复

Use parameterized tests to remove duplication

8.3 避免过度规范

8.3 Avoid overspecification

模拟的内部行为过度规范

Internal behavior overspecification with mocks

精确输出和订购超规格

Exact outputs and ordering overspecification

第 4 部分 设计与流程

Part 4 Design and process

9 可读性

9 Readability

9.1 命名单元测试

9.1 Naming unit tests

9.2 魔法值和命名变量

9.2 Magic values and naming variables

9.3 将断言与动作分开

9.3 Separating asserts from actions

9.4 设置和拆除

9.4 Setting up and tearing down

10 制定测试策略

10 Developing a testing strategy

10.1 常见测试类型和级别

10.1 Common test types and levels

判断测试的标准

Criteria for judging a test

单元测试和组件测试

Unit tests and component tests

集成测试

Integration tests

API测试

API tests

E2E/UI隔离测试

E2E/UI isolated tests

E2E/UI系统测试

E2E/UI system tests

10.2 测试级反模式

10.2 Test-level antipatterns

仅端到端的反模式

The end-to-end-only antipattern

仅低级测试反模式

The low-level-only test antipattern

断开低级和高级测试

Disconnected low-level and high-level tests

10.3 测试配方作为策略

10.3 Test recipes as a strategy

如何编写测试食谱

How to write a test recipe

我什么时候编写和使用测试配方?

When do I write and use a test recipe?

测试配方规则

Rules for a test recipe

10.4 管理交付管道

10.4 Managing delivery pipelines

交付与发现管道

Delivery vs. discovery pipelines

测试层并行化

Test layer parallelization

11 将单元测试集成到组织中

11 Integrating unit testing into the organization

11.1 成为变革推动者的步骤

11.1 Steps to becoming an agent of change

为棘手的问题做好准备

Be prepared for the tough questions

说服业内人士:冠军和阻碍者

Convince insiders: Champions and blockers

确定可能的起点

Identify possible starting points

11.2 成功之道

11.2 Ways to succeed

游击实施(自下而上)

Guerrilla implementation (bottom-up)

令人信服的管理(自上而下)

Convincing management (top-down)

开门实验

Experiments as door openers

获得外部冠军

Get an outside champion

让进步看得见

Make progress visible

瞄准具体目标、指标和 KPI

Aim for specific goals, metrics, and KPIs

意识到会有障碍

Realize that there will be hurdles

11.3 失败的原因

11.3 Ways to fail

缺乏动力

Lack of a driving force

缺乏政治支持

Lack of political support

特别实施和第一印象

Ad hoc implementations and first impressions

缺乏团队支持

Lack of team support

11.4 影响因素

11.4 Influence factors

11.5 棘手的问题和答案

11.5 Tough questions and answers

单元测试会给当前流程增加多少时间?

How much time will unit testing add to the current process?

我的 QA 工作会因为单元测试而面临风险吗?

Will my QA job be at risk because of unit testing?

有证据表明单元测试有帮助吗?

Is there proof that unit testing helps?

为什么 QA 部门仍然发现错误?

Why is the QA department still finding bugs?

我们有很多未经测试的代码:我们从哪里开始?

We have lots of code without tests: Where do we start?

如果我们开发软件和硬件的组合会怎样?

What if we develop a combination of software and hardware?

我们如何知道我们的测试中没有错误?

How can we know we don’t have bugs in our tests?

如果我的调试器显示我的代码可以工作,为什么还需要测试?

Why do I need tests if my debugger shows that my code works?

那么 TDD 呢?

What about TDD?

12 使用遗留代码

12 Working with legacy code

12.1 从哪里开始添加测试?

12.1 Where do you start adding tests?

12.2 选择选择策略

12.2 Choosing a selection strategy

简单优先策略的优缺点

Pros and cons of the easy-first strategy

硬优先策略的优缺点

Pros and cons of the hard-first strategy

12.3 重构前编写集成测试

12.3 Writing integration tests before refactoring

阅读 Michael Feathers 的有关遗留代码的书

Read Michael Feathers’ book on legacy code

使用 CodeScene 调查您的生产代码

Use CodeScene to investigate your production code

附录 Monkey 修补函数和模块

appendix Monkey-patching functions and modules

指数

index

前言

Front matter

第二版前言

foreword to the second edition

那一年一定是 2009 年。我在奥斯陆举行的挪威开发者大会上发表讲话。(啊,六月的奥斯陆!) 活动在一个巨大的体育场举行。会议组织者将看台分成几部分,在看台前搭建舞台,并用厚厚的黑布覆盖,以创建八个不同的会议“房间”。我记得我的演讲刚刚结束,内容是关于 TDD、SOLID、天文学或其他什么的,突然,我旁边的舞台上传来响亮而沙哑的歌声和吉他演奏。

The year must have been 2009. I was speaking at the Norwegian Developers Conference in Oslo. (Ah, Oslo in June!) The event was held in a huge sports arena. The conference organizers divided the bleachers into sections, built stages in front of them, and draped them with thick black cloth in order to create eight different session “rooms.” I remember I was just about finished with my talk, which was about TDD, or SOLID, or astronomy, or something, when suddenly, from the stage next to me, came this loud and raucous singing and guitar playing.

窗帘是如此之大,以至于我能够凝视他们周围,看到舞台上我旁边的那个人,他正在发出所有的噪音。当然,是罗伊·奥谢罗夫。

The drapes were such that I was able to peer around them and see the fellow on the stage next to mine, who was making all the noise. Of course, it was Roy Osherove.

现在,认识我的人都知道,如果我有心情的话,我可能会在一场关于软件的技术演讲中突然唱起歌来。因此,当我转向观众时,我心想,这个奥谢罗夫家伙是志同道合的人,我必须更好地了解他。

Now, those of you who know me know that breaking into song in the middle of a technical talk about software is something that I might just do, if the mood struck me. So as I turned back to my audience, I thought to myself that this Osherove fellow was a kindred spirit, and I’d have to get to know him better.

我所做的就是更好地了解他。事实上,他为我最近的书《The Clean Coder》做出了重大贡献,并花了三天时间与我共同教授 TDD 课程。我与罗伊的经历都是非常积极的,我希望还有更多。

And getting to know him better is just what I did. In fact, he made a significant contribution to my most recent book, The Clean Coder, and spent three days with me co-teaching a TDD class. My experiences with Roy have all been very positive, and I hope there are many more.

我预测你在阅读这本书时与罗伊的经历也会非常积极,因为这本书很特别。

I predict that your experience with Roy, in the reading of this book, will be very positive as well, because this book is something special.

你读过米切纳的小说吗?我没有;但我听说它们都是从“原子”开始的。你手里拿着的这本书不是詹姆斯·米切纳的小说,但它确实从原子开始——单元测试的原子。

Have you ever read a Michener novel? I haven’t; but I’ve been told that they all start at “the atom.” The book you’re holding isn’t a James Michener novel, but it does start at the atom—the atom of unit testing.

当您浏览前面几页时,不要被误导。这不仅仅是单元测试的介绍。它就是这样开始的,如果你有经验,你可以浏览前面的章节。随着本书的进展,各章开始相互叠加,积累了相当惊人的深度。事实上,当我读到最后一章时(不知道这是最后一章),我心想下一章将讨论世界和平——因为,我的意思是,在解决了引入单位的问题之后,你还能去哪里?使用旧的遗留系统测试顽固的组织?

Don’t be misled as you thumb through the early pages. This is not a mere introduction to unit testing. It starts that way, and if you’re experienced you can skim those first chapters. As the book progresses, the chapters start to build on each other into a rather startling accumulation of depth. Indeed, as I read the last chapter (not knowing it was the last chapter), I thought to myself that the next chapter would be dealing with world peace—because, I mean, where else can you go after solving the problem of introducing unit testing into obstinate organizations with old legacy systems?

这本书是技术性的——技术性很强。有很多代码。这是好事。但罗伊并不局限于技术。他时不时地拿出吉他,唱起歌来,讲述他过去的职业轶事,或者对设计的意义或集成的定义进行哲学性的阐述。他似乎很喜欢给我们讲述他在 2006 年黑暗的过去所做的一些非常糟糕的事情的故事。

This book is technical—deeply technical. There’s a lot of code. That’s a good thing. But Roy doesn’t restrict himself to the technical. From time to time he pulls out his guitar and breaks into song as he tells anecdotes from his professional past or waxes philosophical about the meaning of design or the definition of integration. He seems to relish regaling us with stories about some of the things he did really badly in the deep, dark past of 2006.

哦,不用太担心代码都是用 C# 编写的。我的意思是,谁能区分 C# 和 Java 之间的区别呢?正确的?除此之外,这并不重要。他可能使用 C# 作为表达意图的工具,但本书中的课程也适用于 Java、C、Ruby、Python、PHP 或任何其他编程语言(COBOL 除外)。

Oh, and don’t be too concerned that the code is all in C#. I mean, who can tell the difference between C# and Java anyway? Right? And besides, it just doesn’t matter. He may use C# as a vehicle to communicate his intent, but the lessons in this book also apply to Java, C, Ruby, Python, PHP, or any other programming language (except, perhaps COBOL).

如果您是单元测试和测试驱动开发的新手,或者是老手,您会发现本书适合您。因此,准备好享受 Roy 为您唱的歌曲“单元测试的艺术”吧。

If you’re a newcomer to unit testing and test-driven development, or if you’re an old hand at it, you’ll find this book has something for you. So get ready for a treat as Roy sings you the song, “The Art of Unit Testing.”

罗伊,请给吉他调音!

And Roy, please tune that guitar!

——罗伯特·C·马丁(鲍勃叔叔)

cleancoder.com

—Robert C. Martin (Uncle Bob)

cleancoder.com

第一版前言

foreword to the first edition

当 Roy Osherove 告诉我他正在写一本关于单元测试的书时,我很高兴听到。多年来,测试模因在业界一直在兴起,但有关单元测试的可用材料相对缺乏。当我查看我的书架时,我看到专门关于测试驱动开发的书籍和一般关于测试的书籍,但到目前为止还没有关于单元测试的全面参考资料 - 没有一本书介绍该主题并从一开始就指导读者采取广泛接受的最佳实践的步骤。这是事实,这是令人震惊的。单元测试并不是一种新做法。我们是如何走到这一步的?

When Roy Osherove told me that he was working on a book about unit testing, I was very happy to hear it. The testing meme has been rising in the industry for years, but there has been a relative dearth of material available about unit testing. When I look at my bookshelf, I see books that are about test-driven development specifically and books about testing in general, but until now there has been no comprehensive reference for unit testing—no book that introduces the topic and guides the reader from first steps to widely accepted best practices. The fact that this is true is stunning. Unit testing isn’t a new practice. How did we get to this point?

说我们工作在一个非常年轻的行业几乎是陈词滥调,但这是事实。不到 100 年前,数学家为我们的工作奠定了基础,但在过去 60 年里,我们的硬件速度才足以利用他们的见解。我们行业的理论与实践之间最初存在差距,我们现在才发现它如何影响我们的领域。

It’s almost a cliché to say that we work in a very young industry, but it’s true. Mathematicians laid the foundations of our work less than 100 years ago, but we’ve only had hardware fast enough to exploit their insights for the last 60 years. There was an initial gap between theory and practice in our industry, and we’re only now discovering how it has impacted our field.

在早期,机器周期非常昂贵。我们分批运行程序。程序员有一个预定的时间段,他们必须将程序打入一副纸牌中,然后步行到机房。如果你的程序不正确,你就会浪费时间,所以你用铅笔和纸仔细检查你的程序,在心里计算出所有的场景,所有的边缘情况。我怀疑自动化单元测试的概念是否可以想象。当你可以用机器来解决它要解决的问题时,为什么还要使用机器进行测试呢?稀缺使我们陷入黑暗。

In the early days, machine cycles were expensive. We ran programs in batches. Programmers had a scheduled time slot, and they had to punch their programs into decks of cards and walk them to the machine room. If your program wasn’t right, you lost your time, so you desk-checked your program with pencil and paper, mentally working out all of the scenarios, all of the edge cases. I doubt the notion of automated unit testing was even imaginable. Why use the machine for testing when you could use it to solve the problems it was meant to solve? Scarcity kept us in the dark.

后来,机器变得更快,我们沉迷于交互式计算。我们可以只输入代码并随心所欲地更改它。桌面检查代码的想法逐渐消失,我们失去了早年的一些纪律。我们知道编程很困难,但这只是意味着我们必须在计算机上花费更多时间,更改线条和符号,直到找到有效的魔法咒语。

Later, machines became faster and we became intoxicated with interactive computing. We could just type in code and change it on a whim. The idea of desk-checking code faded away, and we lost some of the discipline of the early years. We knew programming was hard, but that just meant that we had to spend more time at the computer, changing lines and symbols until we found the magical incantation that worked.

我们从匮乏走向过剩,错过了中间立场,但现在我们正在重新获得它。自动化单元测试将桌面检查规则与对计算机作为开发资源的新认识结合起来。我们可以用我们开发的语言编写自动化测试来检查我们的工作——不仅仅是一次,而是尽可能频繁地运行它们。我认为在软件开发中没有任何其他实践如此强大。

We went from scarcity to surplus and missed the middle ground, but now we’re regaining it. Automated unit testing marries the discipline of desk-checking with a newfound appreciation for the computer as a development resource. We can write automated tests in the language we develop in to check our work—not just once, but as often as we’re able to run them. I don’t think there is any other practice that’s quite as powerful in software development.

2009 年,当我撰写本文时,我很高兴看到罗伊的书出版。这是一本实用指南,可帮助您入门,并在您执行测试任务时作为很好的参考。《单元测试的艺术》并不是一本关于理想化场景的书。它教您如何测试现场存在的代码,如何利用广泛使用的框架,以及最重要的是,如何编写更容易测试的代码。

As I write this, in 2009, I’m happy to see Roy’s book come into print. It’s a practical guide that will help you get started and also serve as a great reference as you go about your testing tasks. The Art of Unit Testing isn’t a book about idealized scenarios. It teaches you how to test code as it exists in the field, how to take advantage of widely used frameworks, and, most importantly, how to write code that’s far easier to test.

《单元测试的艺术》是一个重要的标题,本应该在几年前就写出来,但我们当时还没有准备好。我们现在准备好了。享受。

The Art of Unit Testing is an important title that should have been written years ago, but we weren’t ready then. We are ready now. Enjoy.

——迈克尔·费瑟斯

对象导师

—Michael Feathers

Object Mentor

前言

preface

我参与过的最失败的项目之一是单元测试。或者说我是这么想的。我带领一群程序员创建一个计费应用程序,我们以完全测试驱动的方式进行 - 编写测试,然后编写代码,看到测试失败,使测试通过,重构,然后从头开始再次。

One of the biggest failed projects I worked on had unit tests. Or so I thought. I was leading a group of programmers creating a billing application, and we were doing it in a fully test-driven manner—writing the test, then writing the code, seeing the test fail, making the test pass, refactoring, and starting all over again.

该项目的前几个月非常棒。一切进展顺利,我们的测试证明我们的代码有效。但随着时间的推移,要求发生了变化。我们被迫更改代码以适应这些新要求,而当我们这样做时,测试就会失败,必须进行修复。代码仍然有效,但我们编写的测试非常脆弱,即使代码运行良好,代码中的任何微小更改都会破坏它们。更改类或方法中的代码成为一项艰巨的任务,因为我们还必须修复所有相关的单元测试。

The first few months of the project were great. Things were going well, and we had tests that proved that our code worked. But as time went by, requirements changed. We were forced to change our code to fit those new requirements, and when we did, tests broke and had to be fixed. The code still worked, but the tests we wrote were so brittle that any little change in our code broke them, even though the code was working fine. It became a daunting task to change code in a class or method because we also had to fix all the related unit tests.

更糟糕的是,一些测试变得无法使用,因为编写它们的人离开了项目,并且没有人知道如何维护测试或他们正在测试什么。我们给单元测试方法起的名称不够清晰,而且我们的测试依赖于其他测试。项目启动不到六个月,我们就放弃了大部分测试。

Worse yet, some tests became unusable because the people who wrote them left the project, and no one knew how to maintain the tests or what they were testing. The names we gave our unit testing methods weren’t clear enough, and we had tests relying on other tests. We ended up throwing out most of the tests less than six months into the project.

该项目惨遭失败,因为我们让自己编写的测试弊大于利。从长远来看,它们花费的维护和理解时间比它们为我们节省的时间还要多,所以我们停止使用它们。我转向其他项目,在这些项目中,我们在编写单元测试方面做得更好,并且使用它们取得了一些巨大的成功,节省了大量的调试和集成时间。自从第一个失败的项目以来,我一直在编译单元测试的最佳实践并将其用于后续项目。我在我从事的每个项目中都找到了一些更多的最佳实践。

The project was a miserable failure because we let the tests we wrote do more harm than good. They took more time to maintain and understand than they saved us in the long run, so we stopped using them. I moved on to other projects, where we did a better job writing our unit tests, and we had some great successes using them, saving huge amounts of debugging and integration time. Since that first failed project, I’ve been compiling best practices for unit tests and using them on subsequent projects. I find a few more best practices with every project I work on.

无论您使用什么语言或集成开发环境,理解如何编写单元测试以及如何使它们可维护、可读和可信都是本书的主题。本书涵盖了编写单元测试的基础知识,接着介绍了交互测试的基础知识,并介绍了在现实世界中编写、管理和维护单元测试的最佳实践。

Understanding how to write unit tests—and how to make them maintainable, readable, and trustworthy—is what this book is about, no matter what language or integrated development environment you work with. This book covers the basics of writing a unit test, moves on to the basics of interaction testing, and introduces best practices for writing, managing, and maintaining unit tests in the real world.

——罗伊·奥谢罗夫

—Roy Osherove

当曼宁请我帮助完成一本即将完成的关于单元测试的书时,我最初的想法是拒绝。毕竟,我已经有了自己的关于单元测试的书,那么我为什么要参与别人的项目呢?但当我意识到这本书正是 Roy 的《单元测试的艺术》时,我改变了主意。《单元测试的艺术》第一版是我读到的关于该主题的第一本书之一,它帮助塑造了我对单元测试的看法。我很荣幸能为这部重要著作的第三版做出贡献。

When Manning asked me to help complete a book on unit testing that was nearly finished, my initial thought was to decline. After all, I already had my own book on unit testing, so why should I work on someone else’s project? But I changed my mind when I realized that the book in question was none other than Roy’s The Art of Unit Testing. The first edition of The Art of Unit Testing was one of the first books I read on the topic, and it helped shape my views on unit testing. I feel honored to contribute to the third edition of this momentous work.

我个人认为这本书是单元测试主题的优秀介绍。一旦您完成并准备好深入研究,请拿起我的书《单元测试原理、实践和模式》(Manning,2020)。

I personally see this book as an excellent introduction to the subject of unit testing. Once you have completed it and are ready to delve deeper, pick up my book, Unit Testing Principles, Practices, and Patterns (Manning, 2020).

——弗拉基米尔·霍里科夫

—Vladimir Khorikov

致谢

acknowledgments

我们要感谢原稿的许多审稿人,他们的反馈帮助我们改进了本书。感谢 Aboudou Samadou Sare、Adhir Ramjiawan、Adriaan Beiertz、Alain Lompo、Barnaby Norman、Charles Lam、Conor Redmond、Daut Morina、Esref Durna、Foster Haines、Harinath Mallepally、Jared Duncan、Jason Hales、Jaume López、Jeremy Chen、Joel霍尔姆斯、约翰·拉森、乔纳森·里夫斯、豪尔赫·E·博、肯特·斯皮尔纳、金·加布里埃尔森、马塞尔·范登布林克、马克·格雷厄姆、马特·范·温克尔、马特奥·巴蒂斯塔、马特奥·吉尔多内、迈克·霍尔科姆、奥利弗·科滕、奥诺弗雷·乔治、保罗·罗巴克、巴勃罗Herrera J.、帕特里斯·马尔达格、拉胡尔·莫德普尔、兰吉特·萨海、Rich Yonts、理查德·迈森、罗德里戈·恩西纳斯、罗纳德·博尔曼、萨钦·辛吉、萨曼莎·伯克、桑德·泽格维尔德、萨特·库马尔·萨胡、Shayn Cornwell、Tanya Wilke、汤姆·马登、乌迪特·巴德瓦吉,和瓦迪姆·图尔科夫。

We would like to thank the many reviewers of the manuscript, whose feedback helped us to improve the book. Thanks go to Aboudou Samadou Sare, Adhir Ramjiawan, Adriaan Beiertz, Alain Lompo, Barnaby Norman, Charles Lam, Conor Redmond, Daut Morina, Esref Durna, Foster Haines, Harinath Mallepally, Jared Duncan, Jason Hales, Jaume López, Jeremy Chen, Joel Holmes, John Larsen, Jonathan Reeves, Jorge E. Bo, Kent Spillner, Kim Gabrielsen, Marcel van den Brink, Mark Graham, Matt Van Winkle, Matteo Battista, Matteo Gildone, Mike Holcomb, Oliver Korten, Onofrei George, Paul Roebuck, Pablo Herrera J., Patrice Maldague, Rahul Modpur, Ranjit Sahai, Rich Yonts, Richard Meinsen, Rodrigo Encinas, Ronald Borman, Sachin Singhi, Samantha Berk, Sander Zegveld, Satej Kumar Sahu, Shayn Cornwell, Tanya Wilke, Tom Madden, Udit Bhardwaj, and Vadim Turkov.

一本成功的书的制作需要很多人的参与。我们要感谢 Manning 收购编辑 Michael Stephens、开发编辑 Connor O'Brien、技术开发编辑 Mike Shepard、技术校对者 Jean-François Morin 以及评论编辑 Adriana Sabo 和 Dunja Nikitović。我们还要感谢曼宁公司的其他所有人,他们参与了第三版的制作和幕后工作。

Many hands go into the making of a successful book. We would like to thank Manning acquisitions editor Michael Stephens, development editor Connor O’Brien, technical development editor Mike Shepard, technical proofreader Jean-François Morin, and review editors Adriana Sabo and Dunja Nikitović. We also thank everyone else at Manning who worked on the third edition in production and behind the scenes.

最后感谢曼宁抢先体验计划中本书的早期读者在在线论坛上提出的评论。你帮助塑造了这本书。

A final word of thanks goes to the early readers of the book in Manning’s Early Access Program for their comments in the online forum. You helped shape the book.

关于这本书

about this book

我听过任何人(我忘了是谁)说过的关于学习的最聪明的事情之一就是,要真正学习某些东西,就要教它。编写本书第一版并于 2009 年出版对我来说绝对是一次真正的学习经历。我最初写这本书是因为我厌倦了一遍又一遍地回答同样的问题。但还有其他原因。我想尝试一些新的东西;我想尝试一个实验;我想知道我能从写一本书中学到什么——任何一本书。我认为单元测试是我所擅长的。诅咒是,你的经验越多,你就越觉得自己愚蠢。

One of the smartest things I ever heard anyone say about learning (and I forget who it was) is that to truly learn something, teach it. Writing the first edition of this book and publishing it in 2009 was nothing short of a true learning experience for me. I initially wrote the book because I got tired of answering the same questions over and over again. But there were other reasons, too. I wanted to try something new; I wanted to try an experiment; I wondered what I could learn from writing a book—any book. Unit testing was what I was good at, I thought. The curse is that the more experience you have, the more stupid you feel.

今天我不同意第一版中的某些部分,例如,单元的是方法。这根本不是真的。正如我在第三版的第一章中讨论的那样,一个单元就是一个工作单元。它可以小到一个方法,也可以大到几个类(可能是程序集),并且还有其他一些事情发生了变化,您接下来将了解到。

There are parts of the first edition that today I do not agree with—for example, that a unit refers to a method. That’s not true at all. A unit is a unit of work, as I discuss in chapter 1 of this third edition. It can be as small as a method, or as big as several classes (possibly assemblies), and there are other things as well that have changed, as you will learn next.

第三版有什么新内容

What’s new in the third edition

在第三版中,我们从 .NET 切换到 JavaScript 和 TypeScript。当然,所有相关的工具和框架也都得到了更新。例如,我们使用 Jest 代替 NUnit 测试运行器和 NSubstitute,既作为单元测试框架又作为模拟库。

In this third edition, we switched from .NET to JavaScript and TypeScript. All the related tools and frameworks got updated, too, of course. For example, instead of NUnit test runner and NSubstitute, we used Jest, both as a unit testing framework and as a mocking library.

我们在有关在组织级别实施单元测试的章节中添加了更多技术。

We added more techniques to the chapter about implementing unit testing at the organizational level.

我们在书中展示的代码中有很多设计更改。它们主要与动态类型语言(例如 JavaScript)的使用相关,但我们也在 TypeScript 的帮助下讨论静态类型技术。

There are plenty of design changes in the code we show in the book. They are mostly related to the use of dynamically typed languages such as JavaScript, but we talk about statically typed techniques as well with the help of TypeScript.

关于测试可信性、可维护性和可读性的讨论已扩展为三个单独的章节。我们还添加了关于测试策略的新章节:如何在不同的测试类型之间做出决定以及使用哪些技术。

The discussion about test trustworthiness, maintainability, and readability has been expanded into three separate chapters. We also added a new chapter about testing strategies: how to decide between different test types and what techniques to use.

谁应该读这本书

Who should read this book

这本书适合任何编写代码并有兴趣学习单元测试最佳实践的人。所有示例都是用 JavaScript 和 TypeScript 编写的,因此 JavaScript 开发人员会发现这些示例特别有用。但我们教授的课程同样适用于大多数(如果不是全部)面向对象和静态类型语言(C#、VB.NET、Java 和 C++,仅举几例)。如果您是架构师、开发人员、团队负责人、QA 工程师(编写代码)或新手程序员,这本书应该很适合您。

The book is for anyone who writes code and is interested in learning best practices for unit testing. All the examples are written in JavaScript and TypeScript, so JavaScript developers will find the examples particularly useful. But the lessons we teach apply equally to most, if not all, object-oriented and statically typed languages (C#, VB.NET, Java, and C++, to name a few). If you’re an architect, developer, team lead, QA engineer (who writes code), or novice programmer, this book should suit you well.

本书的结构:路线图

How this book is organized: A road map

如果您从未编写过单元测试,最好从头到尾阅读本书,以便获得全面的了解。如果您有经验,您应该可以轻松地跳入您认为合适的章节。本书分为四个部分。

If you’ve never written a unit test, it’s best to read this book from start to finish so you get the full picture. If you have experience, you should feel comfortable jumping into the chapters as you see fit. The book is divided into four parts.

第 1 部分将带您从 0 到 60 编写单元测试。第 1 章和第 2 章介绍了基础知识,例如如何使用测试框架 (Jest),并介绍了自动化测试概念,例如测试库、断言库和测试运行器。他们还介绍了断言、忽略测试、工作单元测试、单元测试的三种最终结果以及它们所需的三种测试类型的思想:值测试、基于状态的测试和交互测试。

Part 1 takes you from 0 to 60 in writing unit tests. Chapters 1 and 2 cover the basics, such as how to use a testing framework (Jest), and they introduce automated test concepts, such as test libraries, assertion libraries, and test runners. They also introduce the ideas of asserts, ignoring tests, unit-of-work testing, the three types of end results of a unit test, and the three types of tests you need for them: value tests, state-based tests, and interaction tests.

第 2 部分讨论打破依赖关系的高级技术:模拟对象、桩、隔离框架以及重构代码以使用它们的模式。第 3 章介绍了桩的概念,并展示了如何手动创建和使用它们。第 4 章介绍了模拟对象的交互测试。第 5 章融合了这两个概念,并展示了隔离框架如何结合这两个想法并允许它们自动化。第 6 章深入了解如何测试异步代码。

Part 2 discusses advanced techniques for breaking dependencies: mock objects, stubs, isolation frameworks, and patterns for refactoring your code to use them. Chapter 3 introduces the idea of stubs and shows how to manually create and use them. Chapter 4 introduces interaction testing with mock objects. Chapter 5 merges these two concepts and shows how isolation frameworks combine these two ideas and allow them to be automated. Chapter 6 dives deeper into understanding how to test asynchronous code.

第 3 部分介绍组织测试代码的方法、运行和重构其结构的模式以及编写测试时的最佳实践。第 7 章讨论编写值得信赖的测试的技术。第 8 章讨论了创建可维护测试的单元测试最佳实践。

Part 3 is about ways to organize test code, patterns for running and refactoring its structure, and best practices when writing tests. Chapter 7 discusses techniques for writing tests that you can trust. Chapter 8 discusses best practices in unit testing for creating maintainable tests.

第 4 部分是关于如何在组织中实施变更以及如何处理现有代码。第 9 章是关于测试可读性的。第 10 章展示了如何制定测试策略。第 11 章讨论了在尝试将单元测试引入组织时遇到的问题和解决方案,并确定并回答了在此过程中可能会提出的一些问题。第 12 章讨论了将单元测试引入到遗留代码中。它确定了几种确定从哪里开始测试的方法,并讨论了一些用于测试不可测试代码的工具。

Part 4 is all about how to implement change in an organization and how to work on existing code. Chapter 9 is about test readability. Chapter 10 shows how to develop a testing strategy. Chapter 11 discusses problems and solutions you’d encounter when trying to introduce unit testing into an organization, and it identifies and answers some questions you might be asked in the course of such an effort. Chapter 12 talks about introducing unit testing into legacy code. It identifies a couple of ways to determine where to begin testing and discusses some tools for testing untestable code.

附录列出了您可能会发现在测试工作中有用的猴子修补技术。

The appendix has a list of monkey-patching techniques you might find useful in your testing efforts.

代码约定和下载

Code conventions and downloads

清单或文本中的所有源代码都是这样的,fixed-width font以区别于普通文本。在清单中,bold code表示与上一个示例相比已更改的代码或将在下一个示例中更改的代码。在许多清单中,代码都带有注释以指出关键概念。

All source code in listings or in the text is in a fixed-width font like this to distinguish it from ordinary text. In listings, bold code indicates code that has changed from the previous example or that will change in the next example. In many listings, the code is annotated to point out the key concepts.

您可以从 GitHub (https://github.com/royosherove/aout3-samples)以及出版商的网站(https://www.manning.com/books/the-art-)下载本书的源代码。单元测试第三版。您可以从本书的 liveBook(在线)版本获取可执行代码片段:https://livebook.manning.com/book/the-art-of-unit-testing-third-edition

You can download the source code for this book from GitHub at https://github.com/royosherove/aout3-samples, as well as from the publisher’s website at https://www.manning.com/books/the-art-of-unit-testing-third-edition. You can get executable snippets of code from the liveBook (online) version of this book at https://livebook.manning.com/book/the-art-of-unit-testing-third-edition.

软件要求

Software requirements

要使用本书中的代码,您需要 VS Code(免费)。您还需要 Jest(一个开源免费框架)和其他相关工具。提到的所有工具都是免费的、开源的,或者有试用版,您可以在阅读本书时免费使用。

To use the code in this book, you’ll need VS Code (which is free). You’ll also need Jest (an open source and free framework) and other tools that will be referenced where they’re relevant. All the tools mentioned are either free, open source, or have trial versions you can use freely as you read this book.

LiveBook 讨论论坛

liveBook discussion forum

购买《单元测试的艺术》第三版,包括免费访问曼宁的在线阅读平台 liveBook。使用 liveBook 独有的讨论功能,您可以在全局或特定章节或段落中附加评论。您可以轻松地为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助。要访问论坛,请访问https://livebook.manning.com/book/the-art-of-unit-testing-third-edition/discussion您还可以访问https://livebook.manning.com/discussion了解有关 Manning 论坛和行为规则的更多信息。

Purchase of The Art of Unit Testing, Third Edition, includes free access to liveBook, Manning’s online reading platform. Using liveBook’s exclusive discussion features, you can attach comments to the book globally or to specific sections or paragraphs. It’s a snap to make notes for yourself, ask and answer technical questions, and receive help from the author and other users. To access the forum, go to https://livebook.manning.com/book/the-art-of-unit-testing-third-edition/discussion. You can also learn more about Manning’s forums and the rules of conduct at https://livebook.manning.com/discussion.

曼宁对读者的承诺是提供一个场所,使个人读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与任何具体数量的承诺,他们对论坛的贡献仍然是自愿的(并且是无偿的)。我们建议您尝试问他们一些具有挑战性的问题,以免他们的兴趣消失!只要该书还在印刷,就可以从出版商的网站访问论坛和之前讨论的档案。

Manning’s commitment to our readers is to provide a venue where a meaningful dialogue between individual readers and between readers and authors can take place. It is not a commitment to any specific amount of participation on the part of the authors, whose contribution to the forum remains voluntary (and unpaid). We suggest you try asking them some challenging questions, lest their interest stray! The forum and the archives of previous discussions will be accessible from the publisher’s website as long as the book is in print.

罗伊·奥谢罗夫的其他项目

Other projects by Roy Osherove

Roy 也是《Elastic Leadership: Growing Self-organizing Teams 》(可在www.manning.com/books/elastic-leadership上获取)和《Notes to a Software Team Leader: Growing Self-Organizing Teams》(Team Agile Publishing,2014)的作者。

Roy is also the author of Elastic Leadership: Growing Self-organizing Teams, available at www.manning.com/books/elastic-leadership, and Notes to a Software Team Leader: Growing Self-Organizing Teams (Team Agile Publishing, 2014).

其他资源:

Other resources:

您可以在 X 上关注他 @RoyOsherove。

And you can follow him on X at @RoyOsherove.

弗拉基米尔·霍里科夫的其他项目

Other projects by Vladimir Khorikov

Vladimir 也是《单元测试原理、实践和模式》一书的作者,您可以在https://www.manning.com/books/unit-testing找到该书。

Vladimir is also the author of Unit Testing Principles, Practices, and Patterns, which you can find at https://www.manning.com/books/unit-testing.

其他资源:

Other resources:

您可以在 X 上关注他:@vkhorikov。

And you can follow him on X at @vkhorikov.

关于作者

about the authors

奥谢罗夫



Roy Osherove是 ALT.NET 最初的组织者之一,之前曾在 Typemock 担任首席架构师。他为世界各地的团队提供有关单元测试和测试驱动开发的温和艺术的咨询和培训,并在5whys.com上教授团队领导者如何更好地领导。Roy 在 @RoyOsherove 发推文,并在ArtOfUnitTesting.com上有许多有关单元测试的视频。您还可以在Osherove.com上预约他参加讲座和培训。

Roy Osherove is one of the original ALT.NET organizers and previously worked at Typemock as a chief architect. He consults and trains teams worldwide on the gentle art of unit testing and test-driven development, and he teaches team leaders how to lead better at 5whys.com. Roy tweets at @RoyOsherove and has many videos about unit testing at ArtOfUnitTesting.com. He can also be booked for talks and training at Osherove.com.

霍里科夫



Vladimir Khorikov是 Microsoft MVP、博主和 Pluralsight 作者。他专业从事软件开发已有 10 多年,包括指导团队了解单元测试的细节。Vladimir 是 Manning 出版的《单元测试、原理、实践和模式》一书的作者,他撰写了多篇热门博客文章系列和有关单元测试主题的在线培训课程。他的教学风格的最大优点,也是学生经常称赞的一点,是他倾向于拥有强大的理论背景,然后将其应用到实际例子中。他的博客位于EnterpriseCraftsmanship.com

Vladimir Khorikov is a Microsoft MVP, blogger, and Pluralsight author. He has been professionally involved in software development for more than 10 years, including mentoring teams on the ins and outs of unit testing. Vladimir is the author of Unit Testing, Principles, Practices, and Patterns from Manning, and he has written several popular blog post series and an online training course on the topic of unit testing. The biggest advantage of his teaching style, and the one students often praise, is his tendency to have a strong theoretic background, which he then applies to practical examples. His blog is at EnterpriseCraftsmanship.com.

关于封面插画

about the cover illustration

《单元测试的艺术》第三版封面上的人物是“Japonais encostume de cérémonie”,即“穿着礼服的日本男人”。插图取自 James Prichard 的《人类自然史》,这是一本 1847 年在英国出版的手绘彩色版画书。它是我们的封面设计师在旧金山的一家古董店发现的。

The figure on the cover of The Art of Unit Testing, Third Edition, is a “Japonais en costume de cérémonie,” or a “Japanese man in ceremonial dress.” The illustration is taken from James Prichard’s Natural History of Man, a book of hand-colored lithographs published in England in 1847. It was found by our cover designer in an antique shop in San Francisco.

在那个时代,人们很容易通过衣着来判断他们住在哪里、从事什么行业或生活中的地位。曼宁以几个世纪前丰富的地域文化多样性为基础的书籍封面来颂扬计算机行业的创造力和主动性,并通过像这本书这样的收藏中的图片复活。

In those days, it was easy to identify where people lived and what their trade or station in life was just by their dress. Manning celebrates the inventiveness and initiative of the computer business with book covers based on the rich diversity of regional culture centuries ago, brought back to life by pictures from collections such as this one.

第 1 部分 入门

Part 1 Getting started

本书的这一部分涵盖了单元测试的基础知识。

This part of the book covers the basics of unit testing.

在第一章中,我将定义什么是“单元”以及“好的”单元测试意味着什么,并且我将比较单元测试集成测试。然后我们将了解测试驱动开发及其在单元测试中的作用。

In chapter 1, I’ll define what a “unit” is and what “good” unit testing means, and I’ll compare unit testing with integration testing. Then we’ll look at test-driven development and its role in relation to unit testing.

在第 2 章中,您将尝试使用 Jest(一种常见的 JavaScript 测试框架)编写您的第一个单元测试。您将了解 Jest 的基本 API、如何断言以及如何连续执行测试。

You’ll take a stab at writing your first unit test using Jest (a common JavaScript test framework) in chapter 2. You’ll get to know Jest’s basic API, how to assert things, and how to execute tests continuously.

1 单元测试的基础知识

1 The basics of unit testing

本章涵盖

This chapter covers

  • 识别入口点和出口点
  • Identifying entry points and exit points
  • 单元测试工作单元的定义
  • The definitions of unit test and unit of work
  • 单元测试和集成测试的区别
  • The difference between unit testing and integration testing
  • 单元测试的简单示例
  • A simple example of unit testing
  • 了解测试驱动开发
  • Understanding test-driven development

手动测试很糟糕。您编写代码,在调试器中运行它,在应用程序中按下所有正确的键以使事情顺利进行,然后在下次编写新代码时重复所有这些。并且您必须记住检查可能受新代码影响的所有其他代码。更多的手工工作。伟大的。

Manual tests suck. You write your code, you run it in the debugger, you hit all the right keys in your app to get things just right, and then you repeat all this the next time you write new code. And you have to remember to check all the other code that might have been affected by the new code. More manual work. Great.

完全手动进行测试和回归测试,像猴子一样一次又一次地重复相同的操作,很容易出错,而且很耗时,人们似乎讨厌这样做,就像软件开发中讨厌任何事情一样。这些问题可以通过工具和我们永久使用它的决定来缓解,通过编写自动化测试来节省我们宝贵的时间和调试的痛苦。集成和单元测试框架可帮助开发人员使用一组已知的 API 更快地编写测试、自动执行这些测试并轻松查看这些测试的结果。他们永远不会忘记!我假设你正在读这本书,要么是因为你有同样的感觉,要么是因为有人强迫你读这本书,而有人也有同样的感觉。或者也许是有人强迫你读这本书。没关系。如果您认为重复的手动测试很棒,那么这本书将很难读。假设您学习如何编写良好的单元测试。

Doing tests and regression testing completely manually, repeating the same actions again and again like a monkey, is error prone and time consuming, and people seem to hate doing that as much as anything can be hated in software development. These problems are alleviated by tooling and our decision to use it for good, by writing automated tests that save us precious time and debugging pain. Integration and unit testing frameworks help developers write tests more quickly with a set of known APIs, execute those tests automatically, and review the results of those tests easily. And they never forget! I’m assuming you’re reading this book because either you feel the same way, or because someone forced you to read it, and that someone feels the same way. Or maybe that someone was forced to force you into reading this book. Doesn’t matter. If you believe repetitive manual testing is awesome, this book will be very difficult to read. The assumption is that you want to learn how to write good unit tests.

本书还假设您知道如何使用 JavaScript 或 TypeScript 编写代码,至少使用 ECMAScript 6 (ES6) 功能,并且熟悉节点包管理器 (npm)。另一个假设是您熟悉 Git 源代码管理。如果您以前见过 github.com 并且知道如何从那里克隆存储库,那么您就可以开始了。

This book also assumes that you know how to write code using JavaScript or TypeScript, using at least ECMAScript 6 (ES6) features, and that you are comfortable with node package manager (npm). Another assumption is that you are familiar with Git source control. If you’ve seen github.com before and you know how to clone a repository from there, you are good to go.

尽管本书的所有代码清单都是 JavaScript 和 TypeScript 语言,但您不必是 JavaScript 程序员也可以阅读本书。这本书的前几个版本是用 C# 编写的,我发现其中大约 80% 的模式很容易转移。即使您来自 Java、.NET、Python、Ruby 或其他语言,您也应该能够阅读本书。图案只是图案。该语言用于演示这些模式,但它们不是特定于语言的。

Although all the book’s code listings are in JavaScript and TypeScript, you don’t have to be a JavaScript programmer to read this book. The previous editions of this book were in C#, and I’ve found that about 80% of the patterns there have transferred over quite easily. You should be able to read this book even if you come from Java, .NET, Python, Ruby, or other languages. The patterns are just patterns. The language is used to demonstrate those patterns, but they are not language-specific.

本书中的 JavaScript 与 TypeScript

JavaScript vs. TypeScript in this book

本书通篇包含普通 JavaScript 和 TypeScript 示例。我对创建这样一座巴别塔承担全部责任(没有双关语),但我保证,有一个很好的理由:这本书讨论 JavaScript 中的三种编程范式:过程式设计函数式设计面向对象设计。

This book contains both vanilla JavaScript and TypeScript examples throughout. I take full responsibility for creating such a Tower of Babel (no pun intended), but I promise, there’s a good reason: this book is dealing with three programming paradigms in JavaScript: procedural, functional, and object-oriented design.

我使用常规 JavaScript 来处理程序和功能设计的示例。我将 TypeScript 用于面向对象的示例,因为它提供了表达这些想法所需的结构。

I use regular JavaScript for the samples dealing with procedural and functional designs. I use TypeScript for the object-oriented examples, because it provides the structure needed to express these ideas.

在本书的前几个版本中,当我使用 C# 工作时,这不是问题。当转向支持这些多种范例的 JavaScript 时,使用 TypeScript 是有意义的。

In previous editions of this book, when I was working in C#, this wasn’t an issue. When moving to JavaScript, which supports these multiple paradigms, using TypeScript makes sense.

您可能会问,为什么不将 TypeScript 用于所有范例呢?两者都表明不需要 TypeScript 来编写单元测试,并且单元测试的概念不依赖于一种语言或另一种语言,也不依赖于任何类型的编译器或 linter 来工作。

Why not just use TypeScript for all the paradigms, you ask? Both to show that TypeScript is not needed to write unit tests and that the concepts of unit testing do not depend on one language or another, or on any type of compiler or linter, to work.

这意味着,如果您热衷于函数式编程,那么本书中的一些示例将很有意义,而另一些示例则显得过于复杂或不必要的冗长。请随意只关注功能示例。

This means that if you’re into functional programming, some of the examples in this book will make sense, and others will seem like they are overcomplicated or needlessly verbose. Feel free to focus only on the functional examples.

如果您热衷于面向对象编程或者有 C#/Java 背景,您会发现一些非面向对象示例过于简单化,并不能代表您自己的日常工作项目。不用担心,会有很多与面向对象风格相关的部分。

If you’re into object-oriented programming or are coming from a C#/Java background, you’ll find that some of the non-object-oriented examples are simplistic and don’t represent your day-to-day work in your own projects. Fear not, there will be plenty of sections relating to the object-oriented style.

1.1 第一步

1.1 The first step

总是有第一步:第一次编写程序、第一次失败的项目以及第一次成功实现自己想要完成的目标。你永远不会忘记你的第一次,我希望你也不会忘记你的第一次测试。

There’s always a first step: the first time you wrote a program, the first time you failed a project, and the first time you succeeded in what you were trying to accomplish. You never forget your first time, and I hope you won’t forget your first tests.

您可能遇到过某种形式的测试。您最喜欢的一些开源项目附带捆绑的“测试”文件夹 - 您可以将它们放在您自己的工作项目中。您可能已经自己编写了一些测试,甚至可能记得它们是糟糕的、笨拙的、缓慢的或难以维护的。更糟糕的是,你可能会觉得它们毫无用处并且浪费时间。(可悲的是,很多人都这样做。) 或者,您可能已经在单元测试方面获得了很好的初次体验,并且您正在阅读这本书,看看您可能还缺少什么。

You may have come across tests in some form. Some of your favorite open source projects come with bundled “test” folders—you have them in your own projects at work. You might have already written a few tests yourself, and you may even remember them as being bad, awkward, slow, or unmaintainable. Even worse, you might have felt they were useless and a waste of time. (Many people sadly do.) Or you may have had a great first experience with unit tests, and you’re reading this book to see what more you might be missing.

本章将分析单元测试的“经典”定义,并将其与集成测试的概念进行比较。这种区别让许多人感到困惑,但学习它非常重要,因为正如您将在本书后面学到的那样,将单元测试与其他类型的测试分开对于在测试失败或通过时保持高度信心至关重要。

This chapter will analyze the “classic” definition of a unit test and compare it to the concept of integration testing. This distinction is confusing to many, but it’s very important to learn, because, as you’ll learn later in the book, separating unit tests from other types of tests can be crucial to having high confidence in your tests when they fail or pass.

我们还将讨论单元测试与集成测试的优缺点,并且我们将对什么是“好的”单元测试制定更好的定义。最后我们将介绍一下测试驱动开发 (TDD),因为它通常与单元测试相关,但它是一项独立的技能,我强烈建议您尝试一下(尽管它不是本书的主题)。在本章中,我还将触及本书其他地方更彻底解释的概念。

We’ll also discuss the pros and cons of unit testing versus integration testing, and we’ll develop a better definition of what might be a “good” unit test. We’ll finish with a look at test-driven development (TDD), because it’s often associated with unit testing but is a separate skill that I highly recommend giving a chance (it’s not the main topic of this book, though). Throughout this chapter, I’ll also touch on concepts that are explained more thoroughly elsewhere in the book.

首先,让我们定义单元测试应该是什么。

First, let’s define what a unit test should be.

1.2 逐步定义单元测试

1.2 Defining unit testing, step by step

单元测试并不是软件开发中的新概念。自 20 世纪 70 年代 Smalltalk 编程语言早期以来,它就一直存在,并且一次又一次地证明了自己是开发人员提高代码质量同时更深入地了解模块功能需求的最佳方式之一,类,或者函数。Kent Beck 在 Smalltalk 中引入了单元测试的概念,并将其推广到许多其他编程语言中,使单元测试成为一种非常有用的实践。

Unit testing isn’t a new concept in software development. It’s been floating around since the early days of the Smalltalk programming language in the 1970s, and it proves itself time and time again as one of the best ways a developer can improve code quality while gaining a deeper understanding of the functional requirements of a module, class, or function. Kent Beck introduced the concept of unit testing in Smalltalk, and it has carried on into many other programming languages, making unit testing an extremely useful practice.

要了解我们不想使用什么作为单元测试的定义,让我们以维基百科为起点。我将保留使用它的定义,因为在我看来,它遗漏了许多重要的部分,但由于缺乏其他好的定义,它在很大程度上被许多人接受。我们的定义将在本章中慢慢演变,最终定义出现在第 1.9 节中。

To see what we don’t want to use as our definition of unit testing, let’s look to Wikipedia as a starting point. I’ll use its definition with reservations, because, in my opinion, there are many important parts missing, but it is largely accepted by many for lack of other good definitions. Our definition will slowly evolve throughout this chapter, with the final definition appearing in section 1.9.

单元测试通常是由软件开发人员编写和运行的自动化测试,以确保应用程序的一部分(称为“单元”)满足其设计并按预期运行。在过程式编程中,单元可以是整个模块,但更常见的是单个函数或过程。在面向对象编程中,单元通常是整个接口,例如类或单个方法(https://en.wikipedia.org/wiki/Unit_testing)。

Unit tests are typically automated tests written and run by software developers to ensure that a section of an application (known as the “unit”) meets its design and behaves as intended. In procedural programming, a unit could be an entire module, but it is more commonly an individual function or procedure. In object-oriented programming, a unit is often an entire interface, such as a class, or an individual method (https://en.wikipedia.org/wiki/Unit_testing).

您要为其编写测试的是主题系统或被测套件(SUT)。

The thing you’ll write tests for is the subject, system, or suite under test (SUT).

定义SUT 代表主题系统或测试套件,有些人喜欢使用 CUT(组件、类或测试代码)。当您测试某些东西时,您将正在测试的东西称为 SUT。

Definition SUT stands for subject, system, or suite under test, and some people like to use CUT (component, class, or code under test). When you test something, you refer to the thing you’re testing as the SUT.

我们来谈谈单元测试中的“单元”这个词。对我来说,单元代表系统内的“工作单元”或“用例”。一个工作单元有一个开始和一个结束,我称之为入口点出口点。工作单元的一个简单示例是计算某些内容并返回值的函数。但是,函数还可以在计算过程中使用其他函数、其他模块和其他组件,这意味着工作单元(从入口点到出口点)可以跨越多个函数。

Let’s talk about the word “unit” in unit testing. To me, unit stands for a “unit of work” or a “use case” inside the system. A unit of work has a beginning and an end, which I call an entry point and an exit point. A simple example of a unit of work is a function that calculates something and returns a value. However, a function could also use other functions, other modules, and other components in the calculation process, which means the unit of work (from entry point to exit point), could span more than just a function.

工作单位

Unit of work

工作单元是从调用入口点到通过一个或多个出口点产生明显的最终结果之间发生的所有操作。入口是我们触发的东西。例如,给定一个公开可见的函数

A unit of work is all the actions that take place between the invocation of an entry point up until a noticeable end result through one or more exit points. The entry point is the thing we trigger. Given a publicly visible function, for example

  1. 函数的主体是工作单元的全部或一部分。

  2. The function’s body is all or part of the unit of work.

  3. 函数的声明和签名是函数体的入口点。

  4. The function’s declaration and signature are the entry point into the body.

  5. 函数的输出或行为是其退出点。

  6. The resulting outputs or behaviors of the function are its exit points.

1.3 入口点和出口点

1.3 Entry points and exit points

一个工作单元总是有一个入口点和一个或多个出口点。图 1.1 显示了工作单元的简单图。

A unit of work always has an entry point and one or more exit points. Figure 1.1 shows a simple diagram of a unit of work.

01-01



图 1.1 工作单元有入口点和出口点。

Figure 1.1 A unit of work has entry points and exit points.

一个工作单元可以是单个功能、多个功能,甚至多个模块或组件。但它总是有一个我们可以从外部触发的入口点(通过测试或其他生产代码),并且它最终总是会做一些有用的事情。如果它没有做任何有用的事情,我们不妨将其从我们的代码库中删除。

A unit of work can be a single function, multiple functions, or even multiple modules or components. But it always has an entry point that we can trigger from the outside (via tests or other production code), and it always ends up doing something useful. If it doesn’t do anything useful, we might as well remove it from our codebase.

有什么?代码中发生的一些公开可见的事情:返回值、状态更改或调用外部方,如图 1.2 所示。这些值得注意的行为就是我所说的退出点。

What’s useful? Something publicly noticeable that happens in the code: a return value, a state change, or calling an external party, as shown in figure 1.2. Those noticeable behaviors are what I call exit points.

01-02



图1.2 退出点的类型

Figure 1.2 Types of exit points

何为“退出点”?

Why “exit point”?

为什么使用“退出点”而不是“行为”之类的术语?我的想法是,行为可以纯粹是内部的,而我们正在寻找来自调用者的外部可见的行为。这种差异乍一看可能很难区分。此外,“退出点”很好地表明我们正在离开工作单元的上下文并返回到测试上下文,尽管行为可能比这更加流畅。Vladimir Khorikov 的《单元测试原则、实践和模式》 (Manning,2020)中对行为类型(包括可观察行为)进行了广泛的讨论。请参阅该书以了解有关该主题的更多信息。

Why use the term “exit point” and not something like “behavior”? My thinking is that behaviors can be purely internal, whereas we’re looking for externally visible behaviors from the caller. That difference might be difficult to distinguish at a glance. Also, “exit point” nicely suggests we are leaving the context of a unit of work and going back to the test context, though behaviors might be a bit more fluid than that. There’s an extensive discussion about types of behavior, including observable behavior, in Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020). Refer to that book to learn more about this topic.

以下清单显示了简单工作单元的快速代码示例。

The following listing shows a quick code example of a simple unit of work.

清单 1.1 我们想要测试的一个简单函数

Listing 1.1 A simple function that we’d like to test

const sum = (数字) => {
  const [a, b] = Numbers.split(',');
  const 结果 = parseInt(a) + parseInt(b);
  返回结果;
};
const sum = (numbers) => {
  const [a, b] = numbers.split(',');
  const result = parseInt(a) + parseInt(b);
  return result;
};

关于本书使用的JavaScript版本

About the JavaScript version used in this book

我选择使用 Node.js 12.8 和纯 ES6 JavaScript 以及 JSDoc 风格的注释。为了简单起见,我将使用 CommonJS 模块系统。也许在未来的版本中我将开始使用 ES 模块(.mjs 文件),但现在以及本书的其余部分,CommonJS 就可以了。无论如何,这对于本书中的模式来说并不重要。

I’ve chosen to use Node.js 12.8 with plain ES6 JavaScript along with JSDoc-style comments. The module system I’ll use is CommonJS, to keep things simple. Perhaps in a future edition I’ll start using ES modules (.mjs files), but for now, and for the rest of this book, CommonJS will do. It doesn’t really matter for the patterns in this book anyway.

您应该能够轻松地将此处使用的技术推断为您当前正在使用的任何 JavaScript 堆栈,无论您使用的是 TypeScript、Plain JS、ES 模块、后端还是前端、Angular 还是 React。应该没关系。

You should be able to easily extrapolate the techniques used here for whatever JavaScript stack you’re currently working with, whether you’re using TypeScript, Plain JS, ES modules, backend or frontend, Angular, or React. It shouldn’t matter.

获取本章的代码

Getting the code for this chapter

您可以从 GitHub 下载本书中显示的所有代码示例。您可以在https://github.com/royosherove/aout3-samples找到该存储库。确保您安装了 Node 12.8 或更高版本,然后npm install运行npm run ch[chapter number]​​. 对于本章,您将运行npm run ch1. 这将运行本章的所有测试,以便您可以看到它们的输出。

You can download all the code samples shown in this book from GitHub. You can find the repository at https://github.com/royosherove/aout3-samples. Make sure you have Node 12.8 or higher installed, and run npm install followed by npm run ch[chapter number]. For this chapter, you would run npm run ch1. This will run all the tests for this chapter so you can see their outputs.

该工作单元完全包含在一个函数中。该函数是入口点,并且由于其最终结果返回一个值,因此它也充当出口点。我们在触发工作单元的同一位置获得最终结果,因此入口点也是出口点。

This unit of work is completely encompassed in a single function. The function is the entry point, and because its end result returns a value, it also acts as the exit point. We get the end result in the same place we trigger the unit of work, so the entry point is also the exit point.

如果我们将此函数绘制为一个工作单元,它将如图 1.3 所示。我用作sum(numbers)入口点,而不是numbers,因为入口点是函数签名。参数是通过入口点给出的上下文或输入。

If we drew this function as a unit of work, it would look something like figure 1.3. I used sum(numbers) as the entry point, not numbers, because the entry point is the function signature. The parameters are the context or input given through the entry point.

 

 

01-03



图 1.3 具有相同入口点和出口点的函数

Figure 1.3 A function that has the same entry point as exit point

下面的清单显示了这个想法的一个变体。

The following listing shows a variation on this idea.

清单 1.2 具有入口点和出口点的工作单元

Listing 1.2 A unit of work with entry points and exit points

让总计 = 0;
 
const TotalSoFar = () => {
  返回总计;
};
 
const sum = (数字) => {
  const [a, b] = Numbers.split(',');
  const 结果 = parseInt(a) + parseInt(b);
  总计+=结果;          
  返回结果;
};
let total = 0;
 
const totalSoFar = () => {
  return total;
};
 
const sum = (numbers) => {
  const [a, b] = numbers.split(',');
  const result = parseInt(a) + parseInt(b);
  total += result;          
  return result;
};

新功能:计算运行总计

New functionality: calculating a running total

这个新版本sum两个退出点。它做了两件事:

This new version of sum has two exit points. It does two things:

  • 它返回一个值。

  • It returns a value.

  • 它引入了新功能:所有总和的运行总计。totalSoFar它以一种从入口点的调用者可以注意到的方式(通过 )设置模块的状态。

  • It introduces new functionality: a running total of all the sums. It sets the state of the module in a way that is noticeable (via totalSoFar) from the caller of the entry point.

图 1.4 显示了我如何绘制这个工作单元。您可以将这两个出口点视为来自同一工作单元的两个不同路径或要求,因为它们确实代码预期要做的两个不同的有用的事情。这也意味着我很可能在这里编写两个不同的单元测试:每个退出点一个。很快我们就会做到这一点。

Figure 1.4 shows how I would draw this unit of work. You can think of these two exit points as two different paths, or requirements, from the same unit of work, because they indeed are two different useful things the code is expected to do. This also means I’d be very likely to write two different unit tests here: one for each exit point. Very soon we’ll do exactly that.

01-04



图 1.4 具有两个退出点的工作单元

Figure 1.4 A unit of work with two exit points

关于什么totalSoFar?这也是一个切入点吗?是的,可能是,在单独的测试中。我可以编写一个测试来证明totalSoFar在调用返回之前不触发调用0。这将使其成为自己的小工作单元,这完全没问题。通常,一个工作单元(例如sum)可以由更小的单元组成。

What about totalSoFar? Is this also an entry point? Yes, it could be, in a separate test. I could write a test that proves that calling totalSoFar without triggering prior to that call returns 0. That would make it its own little unit of work, which would be perfectly fine. Often one unit of work (such as sum) can be composed of smaller units.

正如您所看到的,我们的测试范围可以改变和变异,但我们仍然可以用入口点和出口点来定义它们。入口点始终是测试触发工作单元的地方。一个工作单元可以有多个入口点,每个入口点由一组不同的测试使用。

As you can see, the scope of our tests can change and mutate, but we can still define them with entry points and exit points. Entry points are always where the test triggers the unit of work. You can have multiple entry points into a unit of work, each used by a different set of tests.

设计注意事项

A note on design

有两种主要类型的操作:“查询”操作和“命令”操作。查询操作不会改变内容;他们只是返回值。命令操作会更改内容但不返回值。

There are two main types of actions: “query” actions and “command” actions. Query actions don’t change stuff; they just return values. Command actions change stuff but don’t return values.

我们经常将两者结合起来,但在很多情况下,将它们分开可能是更好的设计选择。这本书主要不是关于设计的,但我强烈建议您在 Martin Fowler 的网站上阅读更多有关命令查询分离概念的内容:https: //martinfowler.com/bliki/CommandQuerySeparation.xhtml

We often combine the two, but there are many cases where separating them might be a better design choice. This book isn’t primarily about design, but I urge you to read more about the concept of command query separation over on Martin Fowler’s website: https://martinfowler.com/bliki/CommandQuerySeparation.xhtml.

退出点表示需求和新测试,反之亦然

Exit points signifying requirements and new tests, and vice versa

退出点是工作单元的最终结果。对于单元测试,我通常为每个退出点至少编写一个测试,并具有自己的可读名称。然后,我可以添加更多具有输入变化的测试,所有测试都使用相同的入口点,以获得更多信心。

Exit points are end results of a unit of work. For unit tests, I usually write at least one test, with its own readable name, for each exit point. I may then add more tests with variations on the inputs, all using the same entry point, to gain more confidence.

我们将在本章稍后和本书中讨论集成测试,通常包括多个最终结果,因为不可能在这些级别上分离代码路径。这也是集成测试更难调试、启动、运行和维护的原因之一:正如您很快就会看到的,它们比单元测试要做的事情多得多。

Integration tests, which we’ll discuss later in this chapter and in the book, usually include multiple end results, since it can be impossible to separate code paths at those levels. That’s also one of the reasons integration tests are harder to debug, get up and running, and maintain: they do much more than unit tests, as you’ll soon see.

下面的清单显示了示例函数的第三个版本。

A third version of our example function is shown in the following listing.

清单 1.3 向函数添加记录器调用

Listing 1.3 Adding a logger call to the function

让总计 = 0;
 
const TotalSoFar = () => {
  返回总计;
};
 
const logger = makeLogger();
 
const sum = (数字) => {
  const [a, b] = Numbers.split(',');
 logger.info(                                
    '这是一个非常重要的日志输出',    
    { firstNumWas: a, secondaryNumWas: b });    
 
  const 结果 = parseInt(a) + parseInt(b);
  总计+=结果;
  返回结果;
};
let total = 0;
 
const totalSoFar = () => {
  return total;
};
 
const logger = makeLogger();
 
const sum = (numbers) => {
  const [a, b] = numbers.split(',');
  logger.info(                               
    'this is a very important log output',   
    { firstNumWas: a, secondNumWas: b });    
 
  const result = parseInt(a) + parseInt(b);
  total += result;
  return result;
};

CA新出口点

CA new exit point

您可以看到函数中有一个新的退出点(或要求,或最终结果)。它将某些内容记录到外部实体——可能是文件、控制台或数据库。我们不知道,也不关心。这是第三种退出点:调用第三方。我还喜欢将其称为“调用依赖项”。

You can see that there’s a new exit point (or requirement, or end result) in the function. It logs something to an external entity—perhaps to a file, or the console, or a database. We don’t know, and we don’t care. This is the third type of exit point: calling a third party. I also like to refer to it as “calling a dependency.”

定义依赖关系是我们在单元测试期间无法完全控制的东西。或者,在测试中试图控制它可能会让我们的生活变得痛苦。一些示例包括写入文件的记录器、与网络通信的事物、由其他团队控制的代码、需要很长时间的组件(计算、线程、数据库访问)等等。经验法则是,如果我们可以完全轻松地控制它正在做什么,并且它在内存中运行并且速度很快,那么它就不是依赖项。规则总有例外,但这至少可以帮助您解决 80% 的情况。

DEFINITION A dependency is something we don’t have full control over during a unit test. Or it can be something that trying to control in a test would make our lives miserable. Some examples would include loggers that write to files, things that talk to the network, code that’s controlled by other teams, components that take a long time (calculations, threads, database access), and more. The rule of thumb is that if we can fully and easily control what it’s doing, and it runs in memory, and it’s fast, then it’s not a dependency. There are always exceptions to the rule, but this should get you through 80% of the cases, at least.

图 1.5 显示了我如何绘制具有所有三个出口点的工作单元。此时我们仍在讨论函数大小的工作单元。入口点是函数调用,但现在我们有三个可能的路径或出口点,它们可以做一些有用的事情,并且调用者可以公开验证。

Figure 1.5 shows how I’d draw this unit of work with all three exit points. At this point we’re still discussing a function-sized unit of work. The entry point is the function call, but now we have three possible paths, or exit points, that do something useful and that the caller can verify publicly.

01-05



图 1.5 显示函数的三个退出点

Figure 1.5 Showing three exit points from a function

这就是有趣的地方:对每个出口点进行单独的测试是个好主意。这将使测试更具可读性并且更易于调试或更改,而不会影响其他结果。

Here’s where it gets interesting: it’s a good idea to have a separate test for each exit point. This will make the tests more readable and simpler to debug or change without affecting other outcomes.

1.4 退出点类型

1.4 Exit point types

我们已经看到了三种不同类型的最终结果:

We’ve seen that we have three different types of end results:

  • 调用的函数返回一个有用的值(不是未定义的)。如果这是静态类型语言(例如 Java 或 C#),我们会说它是公共的、非 void 函数。

  • The invoked function returns a useful value (not undefined). If this was in a statically typed language such as Java or C#, we’d say it is a public, non-void function.

  • 调用前后系统的状态或行为发生了明显的变化,无需询问私有状态即可确定。

  • There’s a noticeable change to the state or behavior of the system before and after invocation that can be determined without interrogating private state.

  • 有一个对测试无法控制的第三方系统的标注。该第三方系统不返回任何值,或者该值被忽略。(例如:代码调用了不是您编写的第三方日志系统,并且您无法控制其源代码

  • There’s a callout to a third-party system over which the test has no control. That third-party system doesn’t return any value, or that value is ignored. (Example: the code calls a third-party logging system that was not written by you, and you don’t control its source code.)

XUnit 测试模式的入口点和出口点的定义

XUnit Test Patterns’ definition of entry and exit points

Gerard Meszaros 的书《XUnit 测试模式》 (Addison-Wesley Professional,2007 年)讨论了直接输入和输出以及间接输入和输出的概念。直接输入我喜欢称之为入口点。Meszaros 将其称为“使用组件的前门”。那本书中的间接输出是我提到的另外两种类型的退出点(状态更改和调用第三方)。

Gerard Meszaros’ book XUnit Test Patterns (Addison-Wesley Professional, 2007) discusses the notion of direct inputs and outputs, and indirect inputs and outputs. Direct inputs are what I like to call entry points. Meszaros refers to it as “using the front door” of a component. Indirect outputs in that book are the other two types of exit points I mentioned (state change and calling a third party).

这些想法的两个版本都是并行发展的,但“工作单元”的想法只出现在本书中。对我来说,工作单元加上入口点和出口点比直接和间接的输入和输出更有意义,但您可以认为这是关于如何教授测试范围概念的风格选择。您可以在xunitpatterns.com上找到有关XUnit 测试模式的更多信息。

Both versions of these ideas have evolved in parallel, but the idea of a “unit of work” only appears in this book. A unit of work, coupled with entry and exit points, makes much more sense to me than direct and indirect inputs and outputs, but you can consider this a stylistic choice about how to teach the concept of test scopes. You can find more about XUnit Test Patterns at xunitpatterns.com.

让我们看看入口点和出口点的概念如何影响单元测试的定义:单元测试是一段代码,它调用一个工作单元并检查一个特定的出口点作为该工作单元的最终结果。如果关于最终结果的假设被证明是错误的,则单元测试失败。单元测试的范围可以小到一个函数,也可以大到多个模块或组件,具体取决于入口点和出口点之间使用了多少个函数和模块。

Let’s see how the idea of entry and exit points affects the definition of a unit test: A unit test is a piece of code that invokes a unit of work and checks one specific exit point as an end result of that unit of work. If the assumptions about the end result turn out to be wrong, the unit test has failed. A unit test’s scope can span as little as a function or as much as multiple modules or components, depending on how many functions and modules are used between the entry point and the exit point.

1.5 不同的退出点,不同的技术

1.5 Different exit points, different techniques

为什么我要花这么多时间谈论退出点的类型?因为不仅将每个出口点的测试分开是个好主意,而且不同类型的出口点可能需要不同的技术才能成功测试:

Why am I spending so much time talking about types of exit points? Because not only is it a great idea to separate the tests for each exit point, but different types of exit points might require different techniques to test successfully:

  • 基于返回值的退出点(Meszaros 的XUnit 测试模式中的直接输出)应该是最容易测试的退出点。你触发一个入口点,你会得到一些东西,然后你检查你得到的值。

  • Return-value-based exit points (direct outputs in Meszaros’ XUnit Test Patterns) should be the easiest exit points to test. You trigger an entry point, you get something back, and you check the value you got back.

  • 基于状态的测试(间接输出)通常需要更多的体操。您调用某个内容,然后再进行另一个调用以检查其他内容(或者再次调用前一个内容)以查看一切是否按计划进行。

  • State-based tests (indirect outputs) usually require a little more gymnastics. You call something, and then you do another call to check something else (or you call the previous thing again) to see if everything went according to plan.

在第三方情况(间接输出)中,我们要跨越的障碍最多。我们还没有讨论过这一点,但这就是我们被迫使用模拟对象之类的东西来用我们可以在测试中控制和询问的东西来替换外部系统。我将在本书后面深入讨论这个想法。

In a third-party situation (indirect outputs), we have the most hoops to jump through. We haven’t discussed this yet, but that’s where we’re forced to use things like mock objects to replace the external system with something we can control and interrogate in our tests. I’ll cover this idea deeply later in the book.

哪些出口点造成的问题最多?

Which exit points make the most problems?

根据经验,我尝试主要使用基于返回值或基于状态的测试。如果可以的话,我会尽量避免基于模拟对象的测试,而且通常可以。因此,我通常只有不超过 5% 的测试使用模拟对象进行验证。这些类型的测试使事情变得复杂并且使可维护性变得更加困难。但有时这是无法逃避的,我们将在接下来的章节中讨论它们。

As a rule of thumb, I try to mostly use either return-value-based or state-based tests. I try to avoid mock-object-based tests if I can, and usually I can. As a result, I usually have no more than 5% of my tests using mock objects for verification. Those types of tests complicate things and make maintainability more difficult. Sometimes there’s no escape, though, and we’ll discuss them as we proceed in the next chapters.

1.6 从头开始​​测试

1.6 A test from scratch

让我们回到第一个最简单的代码版本(清单 1.1)并尝试测试它,好吗?如果我们尝试为此编写一个测试,它会是什么样子?

Let’s go back to the first, simplest version of the code (listing 1.1) and try to test it, shall we? If we were to try to write a test for this, what would it look like?

让我们首先采用图 1.6 的视觉方法。我们的入口点是sum一个名为 的字符串的输入numberssum也是我们的退出点,因为我们将从它获取返回值并检查它的值。

Let’s take the visual approach first with figure 1.6. Our entry point is sum with an input of a string called numbers. sum is also our exit point, since we will get a return value back from it and check its value.

01-06



图 1.6 我们测试的直观视图

Figure 1.6 A visual view of our test

可以在不使用测试框架的情况下编写自动化单元测试。事实上,由于开发人员已经养成了自动化测试的习惯,我看到很多人在发现测试框架之前就这样做了。在本节中,我们将在不使用框架的情况下编写这样的测试,以便您可以将此方法与第 2 章中使用框架进行对比。

It’s possible to write an automated unit test without using a test framework. In fact, because developers have gotten more into the habit of automating their testing, I’ve seen plenty of them doing this before discovering test frameworks. In this section, we’ll write such a test without a framework, so that you can contrast this approach with using a framework in chapter 2.

因此,我们假设测试框架不存在(或者我们不知道它们存在)。我们决定从头开始编写我们自己的小型自动化测试。下面的清单显示了一个使用纯 JavaScript 测试我们自己的代码的非常简单的示例。

So, let’s assume test frameworks don’t exist (or that we don’t know they do). We have decided to write our own little automated test from scratch. The following listing shows a very naive example of testing our own code with plain JavaScript.

清单 1.4 一个非常幼稚的测试sum()

Listing 1.4 A very naive test against sum()

const parserTest = () => {
  尝试 {
    const 结果 = sum('1,2');
    如果(结果 === 3){
      console.log('parserTest 示例 1通过' );
    } 别的 {
      throw new Error(`parserTest:预期为 3,但结果为 ${result} `);
    }
  } 捕获 (e) {
    console.error(e.stack);
  }
};
const parserTest = () => {
  try {
    const result = sum('1,2');
    if (result === 3) {
      console.log('parserTest example 1 PASSED');
    } else {
      throw new Error(`parserTest: expected 3 but was ${result}`);
    }
  } catch (e) {
    console.error(e.stack);
  }
};

不,这段代码并不可爱。但这足以解释测试的工作原理。要运行此代码,我们可以执行以下操作:

No, this code is not lovely. But it’s good enough to explain how tests work. To run this code, we can do the following:

  1. 打开命令行并键入空字符串。

  2. Open the command line and type an empty string.

  3. "scripts"在package.json的entry下添加一个entry"test"来执行"node mytest.js",然后npm test在命令行执行。

  4. Add an entry under package.json’s "scripts" entry under "test" to execute "node mytest.js" and then execute npm test on the command line.

下面的清单显示了这一点。

The following listing shows this.

清单 1.5 package.json 文件的开头

Listing 1.5 The beginning of our package.json file

{
  “名称”:“aout3-样本”,
  “版本”:“1.0.0”,
  "description": "单元测试艺术代码示例第三版",
  “主要”:“index.js”,
  “脚本”:{
    “测试”:“节点./ch1-basics/custom-test-phase1.js”,
  }
}
{
  "name": "aout3-samples",
  "version": "1.0.0",
  "description": "Code Samples for Art of Unit Testing 3rd Edition",
  "main": "index.js",
  "scripts": {
    "test": "node ./ch1-basics/custom-test-phase1.js",
  }
}

测试方法调用生产模块(SUT),然后检查返回值。如果不是预期的结果,测试方法会向控制台写入错误和堆栈跟踪。测试方法还会捕获发生的任何异常并将其写入控制台,以便它们不会干扰后续方法的运行。当我们使用测试框架时,通常会自动为我们处理。

The test method invokes the production module (the SUT) and then checks the returned value. If it’s not what’s expected, the test method writes to the console an error and a stack trace. The test method also catches any exceptions that occur and writes them to the console, so that they don’t interfere with the running of subsequent methods. When we use a test framework, that’s usually handled for us automatically.

显然,这是编写此类测试的临时方法。如果您要编写这样的多个测试,您可能希望有一个所有测试都可以使用的通用testcheck方法,并且它可以一致地格式化错误。您还可以添加特殊的帮助器方法来检查空对象、空字符串等内容,这样您就不需要在许多测试中编写相同的长行代码。

Obviously, this is an ad hoc way of writing such a test. If you were to write multiple tests like this, you might want to have a generic test or check method that all tests could use, and which would format the errors consistently. You could also add special helper methods that would check on things like null objects, empty strings, and so on, so that you don’t need to write the same long lines of code in many tests.

以下清单显示了此测试的外观,具有稍微更通用的check功能assertEquals

The following listing shows what this test would look like with a slightly more generic check and assertEquals functions.

清单 1.6 使用该Check方法的更通用的实现

Listing 1.6 Using a more generic implementation of the Check method

const assertEquals = (预期, 实际) => {
  if (实际!==预期) {
    throw new Error(`预期为 ${expected},但实际为 ${actual}`);
  }
};
 
const check = (名称, 实现) => {
  尝试 {
    执行();
    console.log(`${name} 已通过`);
  } 捕获 (e) {
    console.error(`${name} 失败`, e.stack);
  }
};
 
check('用 2 个数字求和应该将它们相加', () => { 
  const result = sum('1,2'); 
  assertEquals(3, result); 
});
 
check('多位数求和应该将它们相加', () => { 
  const result = sum('10,20'); 
  assertEquals(30, result); 
});
const assertEquals = (expected, actual) => {
  if (actual !== expected) {
    throw new Error(`Expected ${expected} but was ${actual}`);
  }
};
 
const check = (name, implementation) => {
  try {
    implementation();
    console.log(`${name} passed`);
  } catch (e) {
    console.error(`${name} FAILED`, e.stack);
  }
};
 
check('sum with 2 numbers should sum them up', () => {
  const result = sum('1,2');
  assertEquals(3, result);
});
 
check('sum with multiple digit numbers should sum them up', () => {
  const result = sum('10,20');
  assertEquals(30, result);
});

我们现在创建了两个辅助方法:assertEquals,它删除了用于写入控制台或引发错误的样板代码,以及check,它采用一个字符串作为测试名称和对实现的回调。然后,它负责捕获任何测试错误,将它们写入控制台,并报告测试的状态。

We’ve now created two helper methods: assertEquals, which removes boilerplate code for writing to the console or throwing errors, and check, which takes a string for the name of the test and a callback to the implementation. It then takes care of catching any test errors, writing them to the console, and reporting on the status of the test.

内置断言

Built-in asserts

需要注意的是,我们不需要编写自己的断言。我们可以轻松地使用 Node.js 的内置断言函数,这些函数最初是为测试 Node.js 本身的内部使用而构建的。我们可以通过导入函数来做到这一点

It’s important to note that we don’t need to write our own asserts. We could have easily used Node.js’s built-in assert functions, which were originally built for internal use in testing Node.js itself. We could do so by importing the functions with

const 断言 = require('断言');
const assert = require('assert'); 

然而,我试图证明这个概念的基本简单性,所以我们将避免这种情况。assert您可以在https://nodejs.org/api/assert.xhtml找到有关 Node.js 模块的更多信息。

However, I’m trying to demonstrate the underlying simplicity of the concept, so we’ll avoid that. You can find more info about Node.js’s assert module at https://nodejs.org/api/assert.xhtml.

请注意,仅使用几个辅助方法,测试就变得更易于阅读和更快地编写。Jest 等单元测试框架可以提供更通用的帮助方法,因此测试更容易编写。我将在第 2 章中讨论这一点。首先,让我们谈谈本书的主题:良好的单元测试。

Notice how the tests are easier to read and faster to write with just a couple of helper methods. Unit testing frameworks such as Jest can provide even more generic helper methods like this, so tests are even easier to write. I’ll talk about that in chapter 2. First, let’s talk a bit about the main subject of this book: good unit tests.

1.7 良好的单元测试的特征

1.7 Characteristics of a good unit test

无论您使用哪种编程语言,定义单元测试最困难的方面之一就是定义好的单元测试的含义。当然,好是相对的,每当我们学习有关编码的新知识时,它就会发生变化。这似乎是显而易见的,但事实并非如此。我需要解释为什么我们需要编写更好的测试——理解工作单元是什么是不够的。

No matter what programming language you’re using, one of the most difficult aspects of defining a unit test is defining what’s meant by a good one. Of course, good is relative, and it can change whenever we learn something new about coding. That may seem obvious, but it really isn’t. I need to explain why we need to write better tests—understanding what a unit of work is isn’t enough.

根据我自己的经验,多年来涉及许多公司和团队,大多数尝试对其代码进行单元测试的人要么在某个时候放弃,要么实际上不执行单元测试。他们浪费大量时间编写有问题的测试,当必须花费大量时间维护测试时,他们就会放弃,或者更糟糕的是,他们不相信自己的结果。

Based on my own experience, involving many companies and teams over the years, most people who try to unit test their code either give up at some point or don’t actually perform unit tests. They waste a lot of time writing problematic tests, and they give up when they have to spend a lot of time maintaining them, or worse, they don’t trust their results.

编写一个糟糕的单元测试是没有意义的,除非您正在学习如何编写一个好的单元测试。编写糟糕的测试弊大于利,例如浪费时间调试有缺陷的测试,浪费时间编写没有任何好处的测试,浪费时间试图理解不可读的测试,以及浪费时间编写测试几个月后才删除它们。维护不良测试以及它们如何影响生产代码的可维护性也是一个巨大的问题。糟糕的测试实际上会降低您的开发速度,不仅在编写测试代码时如此,在编写生产代码时也是如此。我将在本书后面讨论所有这些内容。

There’s no point in writing a bad unit test, unless you’re in the process of learning how to write a good one. There are more downsides than upsides to writing bad tests, such as wasting time debugging buggy tests, wasting time writing tests that bring no benefit, wasting time trying to understand unreadable tests, and wasting time writing tests only to delete them a few months later. There’s also a huge issue with maintaining bad tests, and with how they affect the maintainability of production code. Bad tests can actually slow down your development speed, not only when writing test code, but also when writing production code. I’ll touch on all these things later in the book.

通过了解什么是好的单元测试,您可以确定您不会走上一条以后难以修复的道路,那时代码会变成一场噩梦。我们还将在本书后面定义其他形式的测试(组件、端到端等)。

By learning what a good unit test is, you can be sure you aren’t starting off on a path that will be hard to fix later on, when the code becomes a nightmare. We’ll also define other forms of tests (component, end to end, and more) later in the book.

1.7.1 什么是好的单元测试?

1.7.1 What is a good unit test?

每个好的自动化测试(不仅仅是单元测试)都应该具有以下属性:

Every good automated test (not just unit tests) should have the following properties:

  • 应该很容易理解测试作者的意图。

  • It should be easy to understand the intent of the test author.

  • 它应该易于阅读和书写。

  • It should be easy to read and write.

  • 它应该是自动化的。

  • It should be automated.

  • 它的结果应该是一致的(如果您在运行之间不更改任何内容,它应该始终返回相同的结果)。

  • It should be consistent in its results (it should always return the same result if you don’t change anything between runs).

  • 它应该有用并提供可操作的结果。

  • It should be useful and provide actionable results.

  • 任何人都应该能够通过按下按钮来运行它。

  • Anyone should be able to run it with the push of a button.

  • 当失败时,应该很容易检测到预期的结果并确定如何查明问题。

  • When it fails, it should be easy to detect what was expected and determine how to pinpoint the problem.

一个好的单元测试还应该表现出以下属性:

A good unit test should also exhibit the following properties:

  • 它应该运行得很快。

  • It should run quickly.

  • 它应该完全控制被测试的代码(更多内容将在第 3 章中介绍)。

  • It should have full control of the code under test (more on that in chapter 3).

  • 它应该完全隔离(独立于其他测试运行)。

  • It should be fully isolated (run independently of other tests).

  • 它应该在内存中运行,不需要系统文件、网络或数据库。

  • It should run in memory without requiring system files, networks, or databases.

  • 在有意义的情况下,它应该尽可能同步和线性(如果我们可以帮助的话,不要使用并行线程)。

  • It should be as synchronous and linear as possible when that makes sense (no parallel threads if we can help it).

不可能所有测试都遵循良好单元测试的属性,但这没关系。此类测试将简单地过渡到集成测试领域(第 1.8 节的主题)。尽管如此,还是有一些方法可以重构一些测试以符合这些属性。

It’s impossible for all tests to follow the properties of a good unit test, and that’s fine. Such tests will simply transition to the realm of integration testing (the topic of section 1.8). Still, there are ways to refactor some of your tests to conform to these properties.

用桩替换数据库(或其他依赖项)

Replacing the database (or another dependency) with a stub

我们将在后面的章节中讨论桩,但简而言之,它们是模拟真实依赖项的虚假依赖项。它们的目的是简化测试过程,因为它们更容易设置和维护。

We’ll discuss stubs in later chapters, but, in short, they are fake dependencies that emulate the real ones. Their purpose is to simplify the process of testing because they are easier to set up and maintain.

但要小心内存数据库。它们可以帮助您将测试彼此隔离(只要您不在测试之间共享数据库实例),从而遵守良好单元测试的属性,但此类数据库会导致尴尬的中间位置。内存数据库不像桩那么容易设置。同时,它们不提供像真实数据库那样强有力的保证。从功能角度来看,内存数据库可能与生产数据库有很大不同,因此通过内存数据库的测试可能会失败,反之亦然。您通常必须针对生产数据库手动重新运行相同的测试,以获得代码工作的额外信心。除非您使用一小部分标准化的 SQL 功能,否则我建议坚持使用桩(用于单元测试)或真实数据库(用于集成测试)。

Beware of in-memory databases, though. They can help you isolate tests from each other (as long as you don’t share database instances between tests) and thus adhere to the properties of good unit tests, but such databases lead to an awkward, in-between spot. In-memory databases aren’t as easy to set up as stubs. At the same time, they don’t provide as strong guarantees as real databases. Functionality-wise, an in-memory database may differ drastically from the production one, so tests that pass an in-memory database may fail the real one, and vice versa. You’ll often have to rerun the same tests manually against the production database to gain additional confidence that your code works. Unless you use a small and standardized set of SQL features, I recommend sticking to either stubs (for unit tests) or real databases (for integration testing).

对于 jsdom 这样的解决方案也是如此。您可以使用它来替换真实的 DOM,但请确保它支持您的特定用例。不要编写需要您手动重新检查的测试。

The same is true for solutions like jsdom. You can use it to replace the real DOM, but make sure it supports your particular use cases. Don’t write tests that require you to manually recheck them.

通过线性同步测试模拟异步处理

Emulating asynchronous processing with linear, synchronous tests

随着 Promise 和 的出现async/await,异步编码已成为 JavaScript 中的标准。不过,我们的测试仍然可以同步验证异步代码。通常,这意味着直接从测试触发回调或显式等待异步操作完成执行。

With the advent of promises and async/await, asynchronous coding has become standard in JavaScript. Our tests can still verify asynchronous code synchronously, though. Usually that means triggering callbacks directly from the test or explicitly waiting for an asynchronous operation to finish executing.

1.7.2 单元测试清单

1.7.2 A unit test checklist

许多人将测试软件的行为与单元测试的概念混淆了。首先,问自己以下有关您迄今为止编写和执行的测试的问题:

Many people confuse the act of testing their software with the concept of a unit test. To start off, ask yourself the following questions about the tests you’ve written and executed up to now:

  • 我可以运行两周前、几个月前或几年前编写的测试并获得结果吗?

  • Can I run and get results from a test I wrote two weeks or months or years ago?

  • 我的团队中的任何成员都可以运行我两个月前编写的测试并获得结果吗?

  • Can any member of my team run and get results from tests I wrote two months ago?

  • 我可以在几分钟内运行我编写的所有测试吗?

  • Can I run all the tests I’ve written in no more than a few minutes?

  • 我可以通过按一下按钮来运行我编写的所有测试吗?

  • Can I run all the tests I’ve written at the push of a button?

  • 我可以在几分钟之内编写一个基本测试吗?

  • Can I write a basic test in no more than a few minutes?

  • 当其他团队的代码中存在错误时,我的测试能否通过?

  • Do my tests pass when there are bugs in another team’s code?

  • 在不同的机器或环境上运行时,我的测试是否显示相同的结果?

  • Do my tests show the same results when run on different machines or environments?

  • 如果没有数据库、网络或部署,我的测试会停止工作吗?

  • Do my tests stop working if there’s no database, network, or deployment?

  • 如果我删除、移动或更改一项测试,其他测试是否不受影响?

  • If I delete, move, or change one test, do other tests remain unaffected?

如果您对这些问题中的任何一个回答“否”,则您所实现的内容很可能不是完全自动化的,或者不是单元测试。这绝对是某种测试,它可能与单元测试一样重要,但与让您对所有这些问题回答“是”的测试相比,它有缺点。

If you answered “no” to any of these questions, there’s a high probability that what you’re implementing either isn’t fully automated or it isn’t a unit test. It’s definitely some kind of test, and it might be as important as a unit test, but it has drawbacks compared to tests that would let you answer yes to all of those questions.

“到现在为止我在做什么?” 你可能会问。您一直在进行集成测试。

“What was I doing until now?” you might ask. You’ve been doing integration testing.

1.8 集成测试

1.8 Integration tests

我认为集成测试是任何不符合前面概述的良好单元测试的一个或多个条件的测试。例如,如果测试使用真实的网络、真实的 REST API、真实的系统时间、真实的文件系统或真实的数据库,那么它就进入了集成测试的领域。

I consider integration tests to be any tests that don’t live up to one or more of the conditions outlined previously for good unit tests. For example, if the test uses the real network, the real rest APIs, the real system time, the real filesystem, or a real database, it has stepped into the realm of integration testing.

例如,如果测试无法控制系统时间,并且它使用new Date()测试代码中的当前时间,则每次执行测试时,它本质上都是不同的测试,因为它使用不同的时间。它不再一致。这本身并不是一件坏事。我认为集成测试是单元测试的重要对应部分,但它们应该与单元测试分开,以达到一种“安全的绿色区域”的感觉,这将在本书后面讨论。

If a test doesn’t have control of the system time, for example, and it uses the current new Date() in the test code, then every time the test executes, it’s essentially a different test because it uses a different time. It’s no longer consistent. That’s not a bad thing per se. I think integration tests are important counterparts to unit tests, but they should be separated from them to achieve a feeling of “safe green zone,” which is discussed later in this book.

如果测试使用真实数据库,它就不再只在内存中运行,它的操作比仅使用内存中的假数据更难擦除。测试也将运行更长的时间,并且我们将无法轻松控制数据访问所需的时间。单元测试应该很快。集成测试通常要慢得多。当您开始进行数百次测试时,每半秒都很重要。

If a test uses the real database, it’s no longer only running in memory—its actions are harder to erase than when using only in-memory fake data. The test will also run longer, and we won’t easily be able to control how long data access takes. Unit tests should be fast. Integration tests are usually much slower. When you start having hundreds of tests, every half-second counts.

集成测试增加了另一个问题的风险:同时测试太多东西。例如,假设你的车坏了。您如何了解问题所在,更不用说解决问题了?引擎由许多协同工作的子系统组成,每个子系统都依赖其他子系统来帮助产生最终结果:一辆移动的汽车。如果汽车停止行驶,则故障可能出在任何一个子系统上,或者多个子系统上。正是这些子系统(或层)的集成使汽车得以移动。您可以将汽车的运动视为汽车在路上行驶时这些部件的最终集成测试。如果测试失败,所有部分都会一起失败;如果成功,所有部分都会成功。

Integration tests increase the risk of another problem: testing too many things at once. For example, suppose your car breaks down. How do you learn what the problem is, let alone fix it? An engine consists of many subsystems working together, each relying on the others to help produce the final result: a moving car. If the car stops moving, the fault could be with any of the subsystems—or with more than one. It’s the integration of those subsystems (or layers) that makes the car move. You could think of the car’s movement as the ultimate integration test of these parts as the car goes down the road. If the test fails, all the parts fail together; if it succeeds, all the parts succeed.

同样的事情也发生在软件中。大多数开发人员测试其功能的方式是通过应用程序或 REST API 或 UI 的最终功能。单击某个按钮会触发一系列事件——函数、模块和组件一起工作以产生最终结果。如果测试失败,所有这些软件组件都会作为一个整体失败,并且很难找出导致整体操作失败的原因(见图 1.7)。

The same thing happens in software. The way most developers test their functionality is through the final functionality of the app or REST API or UI. Clicking some button triggers a series of events—functions, modules, and components working together to produce the final result. If the test fails, all of these software components fail as a team, and it can be difficult to figure out what caused the failure of the overall operation (see figure 1.7).

01-07



图 1.7 在集成测试中可能会有很多失败点。所有单元都必须协同工作,每个单元都可能发生故障,从而使查找错误来源变得更加困难。

Figure 1.7 You can have many failure points in an integration test. All the units have to work together, and each could malfunction, making it harder to find the source of a bug.

正如Bill Hetzel 的《软件测试完整指南》(Wiley,1988)中所定义的,集成测试是“一个有序的测试过程,其中软件和/或硬件元素被组合和测试,直到整个系统被集成。” 这是我自己定义集成测试的变体:

As defined in The Complete Guide to Software Testing by Bill Hetzel (Wiley, 1988), integration testing is “an orderly progression of testing in which software and/or hardware elements are combined and tested until the entire system has been integrated.” Here’s my own variation on defining integration testing:

集成测试是在不完全控制其所有实际依赖项的情况下测试一个工作单元,例如其他团队的其他组件、其他服务、时间、网络、数据库、线程、随机数生成器等。

Integration testing is testing a unit of work without having full control over all of its real dependencies, such as other components by other teams, other services, the time, the network, databases, threads, random number generators, and more.

总而言之,集成测试使用真实的依赖关系;单元测试将工作单元与其依赖关系隔离开来,以便它们的结果很容易保持一致,并且可以轻松控制和模拟单元行为的任何方面。

To summarize, an integration test uses real dependencies; unit tests isolate the unit of work from its dependencies so that they’re easily consistent in their results and can easily control and simulate any aspect of the unit’s behavior.

让我们将 1.7.2 节中的问题应用到集成测试中,并考虑您希望通过实际单元测试实现什么目标:

Let’s apply the questions from section 1.7.2 to integration tests and consider what you want to achieve with real-world unit tests:

  • 我可以运行两周前、几个月前或几年前编写的测试并获得结果吗?

    如果不能,您如何知道您是否破坏了之前创建的功能?共享数据和代码在应用程序的生命周期中定期更改,如果您在更改代码后无法(或不会)对所有以前工作的功能运行测试,您可能会在不知情的情况下破坏它 - 这称为回归。当开发人员面临修复现有错误的压力时,在冲刺或发布即将结束时,回归似乎经常发生。有时,他们在解决旧错误时会无意中引入新错误。知道自己在破坏某个东西后的 60 秒内就破坏了它,这不是很棒吗?您将在本书后面看到如何做到这一点。

  • Can I run and get results from a test I wrote two weeks or months or years ago?

    If you can’t, how would you know whether you broke a feature that you created earlier? Shared data and code changes regularly during the life of an application, and if you can’t (or won’t) run tests for all the previously working features after changing your code, you just might break it without knowing—this is known as a regression. Regressions seem to occur a lot near the end of a sprint or release, when developers are under pressure to fix existing bugs. Sometimes they introduce new bugs inadvertently as they resolve old ones. Wouldn’t it be great to know that you broke something within 60 seconds of breaking it? You’ll see how that can be done later in this book.

定义回归是功能被破坏——曾经可以工作的代码。您还可以将其视为曾经有效但现在无效的一个或多个工作单元。

Definition A regression is broken functionality—code that used to work. You can also think of it as one or more units of work that once worked and now don’t.

  • 我的团队中的任何成员都可以运行我两个月前编写的测试并获得结果吗?

    这与上一点相一致,但又更上一层楼。您需要确保在更改某些内容时不会破坏其他人的代码。许多开发人员担心更改旧系统中的遗留代码,因为担心不知道其他代码取决于他们正在更改的内容。从本质上讲,他们冒着将系统转变为未知稳定状态的风险。

    没有什么比不知道应用程序是否仍然有效更可怕的了,尤其是当您没有编写该代码时。如果您拥有单元测试的安全网并且知道您没有破坏任何东西,那么您就不会那么害怕接受您不太熟悉的代码。

    任何人都可以访问和运行好的测试。

  • Can any member of my team run and get results from tests I wrote two months ago?

    This goes with the previous point but takes it up a notch. You want to make sure that you don’t break someone else’s code when you change something. Many developers fear changing legacy code in older systems for fear of not knowing what other code depends on what they’re changing. In essence, they risk changing the system into an unknown state of stability.

    Few things are scarier than not knowing whether the application still works, especially when you didn’t write that code. If you have that safety net of unit tests and know you aren’t breaking anything, you’ll be much less afraid of taking on code you’re less familiar with.

    Good tests can be accessed and run by anyone.

定义 遗留代码被维基百科定义为“标准硬件和环境不再支持的旧计算机源代码”(https://en.wikipedia.org/wiki/Legacy_system),但许多商店指的是任何旧版本的该应用程序当前正在作为遗留代码进行维护。它通常指的是难以使用、难以测试、甚至通常难以阅读的代码。一位客户曾经以脚踏实地的方式定义遗留代码:“有效的代码”。许多人喜欢将遗留代码定义为“没有测试的代码”。Michael Feathers 的《有效处理遗留代码》(Pearson,2004 年)使用“没有测试的代码”作为遗留代码的官方定义,这是阅读本书时需要考虑的定义。

Definition Legacy code is defined by Wikipedia as “old computer source code that is no longer supported on the standard hardware and environments” (https://en.wikipedia.org/wiki/Legacy_system), but many shops refer to any older version of the application currently under maintenance as legacy code. It often refers to code that’s hard to work with, hard to test, and usually even hard to read. A client once defined legacy code in a down-to-earth way: “code that works.” Many people like to define legacy code as “code that has no tests.” Working Effectively with Legacy Code by Michael Feathers (Pearson, 2004) uses “code that has no tests” as an official definition of legacy code, and it’s a definition to be considered while reading this book.

  • 我可以在几分钟内运行我编写的所有测试吗?

    如果您无法快速运行测试(几秒钟比几分钟更好),您将减少运行测试的频率(每天,甚至在某些地方每周或每月)。问题是,当你更改代码时,你希望尽早获得反馈,看看你是否破坏了某些东西。运行测试之间所需的时间越长,对系统所做的更改就越多,并且当您发现破坏了某些内容时,您必须在(许多)更多地方搜索错误。

    好的测试应该运行得很快

  • Can I run all the tests I’ve written in no more than a few minutes?

    If you can’t run your tests quickly (seconds are better than minutes), you’ll run them less often (daily, or even weekly or monthly in some places). The problem is that when you change code, you want to get feedback as early as possible to see if you broke something. The more time required between running the tests, the more changes you make to the system, and the (many) more places you’ll have to search for bugs when you find that you broke something.

    Good tests should run quickly.

  • 我可以通过按一下按钮来运行我编写的所有测试吗?

    如果不能,则可能意味着您必须配置将运行测试的计算机,以便它们正确运行(例如,设置 Docker 环境,或设置数据库的连接字符串),或者可能意味着您的单元测试不是完全自动化的。如果您无法完全自动化单元测试,您可能会避免重复运行它们,团队中的其他人也是如此。

    没有人喜欢陷入配置细节来运行测试,只是为了确保系统仍然正常工作。开发人员有更重要的事情要做,比如向系统写入更多功能。但如果他们不知道系统的状态,他们就无法做到这一点。

    好的测试应该能够以原始形式轻松执行,而不是手动执行。

  • Can I run all the tests I’ve written at the push of a button?

    If you can’t, it probably means that you have to configure the machine on which the tests will run so that they run correctly (setting up a Docker environment, or setting connection strings to the database, for example), or it may mean that your unit tests aren’t fully automated. If you can’t fully automate your unit tests, you’ll probably avoid running them repeatedly, as will everyone else on your team.

    No one likes to get bogged down with configuring details to run tests, just to make sure that the system still works. Developers have more important things to do, like writing more features into the system. But they can’t do that if they don’t know the state of the system.

    Good tests should be easily executed in their original form, not manually.

  • 我可以在几分钟之内编写一个基本测试吗?

    发现集成测试的最简单方法之一是正确准备和实施需要时间,而不仅仅是执行。由于所有内部依赖项(有时是外部依赖项),需要花一些时间来弄清楚如何编写它。(数据库可能被视为外部依赖项。)如果您没有自动化测试,依赖项就不是什么问题,但您将失去自动化测试的所有好处。编写测试越困难,您编写更多测试或关注除您担心的“大事”之外的任何事情的可能性就越小。单元测试的优点之一是它们倾向于测试每一个可能损坏的小东西,而不仅仅是大东西。人们常常惊讶地发现,在他们认为简单且没有错误的代码中竟然发现了如此之多的错误。

    当您只专注于大型测试时,对代码的整体信心仍然非常缺乏。代码的核心逻辑的许多部分都没有经过测试(即使您可能覆盖了更多组件),并且可能存在许多您没有考虑过并且可能“非正式”担心的错误。

    一旦您弄清楚了要用来测试一组特定的对象、函数和依赖项(域模型)的模式,针对系统的良好测试应该可以轻松快速地编写。

  • Can I write a basic test in no more than a few minutes?

    One of the easiest ways to spot an integration test is that it takes time to prepare correctly and to implement, not just to execute. It takes time to figure out how to write it because of all the internal, and sometimes external, dependencies. (A database may be considered an external dependency.) If you’re not automating the test, dependencies are less of a problem, but you’re losing all the benefits of an automated test. The harder it is to write a test, the less likely you are to write more tests or to focus on anything other than the “big stuff” that you’re worried about. One of the strengths of unit tests is that they tend to test every little thing that might break, not only the big stuff. People are often surprised at how many bugs they can find in code they thought was simple and bug free.

    When you concentrate only on the big tests, the overall confidence in your code is still very much lacking. Many parts of the code’s core logic aren’t tested (even though you may be covering more components), and there may be many bugs that you haven’t considered and might be “unofficially” worried about.

    Good tests against the system should be easy and quick to write, once you’ve figured out the patterns you want to use to test your specific set of objects, functions, and dependencies (the domain model).

  • 当其他团队的代码中存在错误时,我的测试能否通过?在不同的机器或环境上运行时,我的测试是否显示相同的结果?如果没有数据库、网络或部署,我的测试会停止工作吗?

    这三点指的是我们的测试代码与各种依赖项隔离的想法。测试结果是一致的,因为我们可以控制系统的间接输入提供的内容。我们可以拥有虚假的数据库、虚假的网络、虚假的时间和虚假的机器文化。在后面的章节中,我将把这些点称为接缝,我们可以在其中注入这些桩。

  • Do my tests pass when there are bugs in another team’s code? Do my tests show the same results when run on different machines or environments? Do my tests stop working if there’s no database, network, or deployment?

    These three points refer to the idea that our test code is isolated from various dependencies. The test results are consistent because we have control over what those indirect inputs into our system provide. We can have fake databases, fake networks, fake time, and fake machine culture. In later chapters, I’ll refer to those points as stubs and seams in which we can inject those stubs.

  • 如果我删除、移动或更改一项测试,其他测试是否不受影响?

    单元测试通常不需要任何共享状态,但集成测试通常需要,例如外部数据库或服务。共享状态可以在测试之间创建依赖关系。例如,以错误的顺序运行测试可能会破坏未来测试的状态。

  • If I delete, move, or change one test, do other tests remain unaffected?

    Unit tests usually don’t need to have any shared state, but integration tests often do, such as an external database or service. Shared state can create a dependency between tests. For example, running tests in the wrong order can corrupt the state for future tests.

警告即使是经验丰富的单元测试人员也会发现,可能需要 30 分钟或更长时间才能弄清楚如何针对他们以前从未进行过单元测试的域模型编写第一个单元测试。这是工作的一部分,也是意料之中的。一旦您弄清楚了工作单元的入口点和出口点,对该域模型的第二次和后续测试应该很容易完成。

WARNING Even experienced unit testers can find that it may take 30 minutes or more to figure out how to write the very first unit test against a domain model they’ve never unit tested before. This is part of the work and is to be expected. The second and subsequent tests on that domain model should be very easy to accomplish once you’ve figured out the entry and exit points of the unit of work.

我们可以从前面的问题和答案中认识到三个主要标准:

We can recognize three main criteria in the previous questions and answers:

  • 可读性——如果我们无法阅读它,那么就很难维护,很难调试,也很难知道出了什么问题。

  • Readability—If we can’t read it, then it’s hard to maintain, hard to debug, and hard to know what’s wrong.

  • 可维护性——如果维护测试或生产代码因为测试而变得痛苦,那么我们的生活将变成一场活生生的噩梦。

  • Maintainability—If maintaining the test or production code is painful because of the tests, our lives will become a living nightmare.

  • 信任——如果我们在测试失败时不相信测试结果,我们将再次开始手动测试,从而失去测试应提供的所有好处。如果我们在测试通过时不信任它们,我们将开始更多调试,再次失去任何时间优势。

  • Trust—If we don’t trust the results of our tests when they fail, we’ll start manually testing again, losing all the time benefit the tests are supposed to provide. If we don’t trust the tests when they pass, we’ll start debugging more, again losing any time benefit.

到目前为止,我已经解释了单元测试不是什么以及需要提供哪些功能才能使测试有用,现在我可以开始回答本章提出的主要问题:什么是好的单元测试?

From what I’ve explained so far about what a unit test is not and what features need to be present for testing to be useful, I can now start to answer the primary question this chapter poses: what is a good unit test?

1.9 最终确定我们的定义

1.9 Finalizing our definition

现在我已经介绍了单元测试应具有的重要属性,我将一劳永逸地定义单元测试:

Now that I’ve covered the important properties that a unit test should have, I’ll define unit tests once and for all:

单元测试是一段自动化的代码,它通过入口点调用工作单元,然后检查其出口点之一。单元测试几乎总是使用单元测试框架编写。它可以很容易地编写并且运行得很快。它是值得信赖、可读且可维护的。只要我们控制的生产代码没有改变,它就是一致的。

A unit test is an automated piece of code that invokes the unit of work through an entry point and then checks one of its exit points. A unit test is almost always written using a unit testing framework. It can be written easily and runs quickly. It’s trustworthy, readable, and maintainable. It is consistent as long as the production code we control has not changed.

这个定义看起来确实是一个艰巨的任务,特别是考虑到有多少开发人员执行单元测试很差。它让我们认真审视我们作为开发人员迄今为止实现测试的方式,与我们想要的实现方式进行比较。(第 7 章到第 9 章深入讨论了可信、可读和可维护的测试。)

This definition certainly looks like a tall order, particularly considering how many developers implement unit tests poorly. It makes us take a hard look at the way we, as developers, have implemented testing up until now, compared to how we’d like to implement it. (Trustworthy, readable, and maintainable tests are discussed in depth in chapters 7 through 9.)

在本书的第一版中,我对单元测试的定义略有不同。我曾经将单元测试定义为“仅针对控制流代码运行”,但我不再认为这是真的。没有逻辑的代码通常用作工作单元的一部分。即使没有逻辑的属性也会被工作单元使用,因此测试不必专门针对它们。

In the first edition of this book, my definition of a unit test was slightly different. I used to define a unit test as “only running against control flow code,” but I no longer think that’s true. Code without logic is usually used as part of a unit of work. Even properties with no logic will get used by a unit of work, so they don’t have to be specifically targeted by tests.

定义 控制流代码是任何包含某种逻辑的代码,无论它有多小。它具有以下一项或多项:if语句、循环、计算或任何其他类型的决策代码。

Definition Control flow code is any piece of code that has some sort of logic in it, small as it may be. It has one or more of the following: an if statement, a loop, calculations, or any other type of decision-making code.

Getter 和 Setter 是代码的良好示例,通常不包含任何逻辑,因此不需要测试的特定目标。您正在测试的工作单元可能会使用它的代码,但无需直接测试它。但请注意:一旦在 getter 或 setter 中添加任何逻辑,您将需要确保逻辑正在被测试。

Getters and setters are good examples of code that usually doesn’t contain any logic and so don’t require specific targeting by the tests. It’s code that will probably get used by the unit of work you’re testing, but there’s no need to test it directly. But watch out: once you add any logic inside a getter or setter, you’ll want to make sure that logic is being tested.

在下一节中,我们将不再讨论什么是好的测试,而是讨论何时需要编写测试。我将讨论测试驱动开发,因为它通常与单元测试放在同一位置。我想确保我们澄清这一点。

In the next section, we’ll stop talking about what is a good test and talk about when you might want to write tests. I’ll discuss test-driven development, because it is often put in the same bucket as doing unit testing. I want to make sure we set the record straight on that.

1.10 测试驱动开发

1.10 Test-driven development

一旦您知道如何使用单元测试框架编写可读、可维护且值得信赖的测试,下一个问题就是何时编写测试。许多人认为为软件编写单元测试的最佳时间是在创建一些功能之后并将代码合并到远程源代码管理之前。

Once you know how to write readable, maintainable, and trustworthy tests with a unit testing framework, the next question is when to write the tests. Many people feel that the best time to write unit tests for software is after they’ve created some functionality and just before they merge their code into remote source control.

另外,坦白地说,很多人不认为编写测试是一个好主意,但通过反复试验意识到源代码控制审查中有严格的测试要求,因此他们必须编写测试来安抚代码审查上帝并将他们的代码合并到主分支中。(这种动态是不良测试的重要来源,我将在本书的第三部分中解决它。)

Also, to be a bit blunt, a lot of people don’t believe writing tests is a good idea, but have realized through trial and error that there are strict testing requirements in source control reviews, so they have to write tests to appease the code review gods and get their code merged into the main branch. (That kind of dynamic is a great source of bad tests, and I’ll address it in the third part of this book.)

越来越多的开发人员更喜欢在编码会话期间以及实现每个非常小的功能之前增量编写单元测试。这种方法称为测试优先测试驱动开发 (TDD)。

A growing number of developers prefer writing unit tests incrementally, during the coding session and before each piece of very small functionality is implemented. This approach is called test-first or test-driven development (TDD).

注意对于测试驱动开发的确切含义有许多不同的观点。有人说这是测试优先的开发,有人说这意味着你有很多测试。有人说这是一种设计方式,而另一些人则认为这可能是一种仅通过一些设计即可驱动代码行为的方式。在本书中,TDD 意味着测试优先开发,设计在技术中扮演增量角色(除了本节之外,本书不会讨论 TDD)。

Note There are many different views on exactly what test-driven development means. Some say it’s test-first development, and some say it means you have a lot of tests. Some say it’s a way of designing, and others feel it could be a way to drive your code’s behavior with only some design. In this book, TDD means test-first development, with design taking an incremental role in the technique (besides this section, TDD won’t be discussed in this book).

图 1.8 和图 1.9 显示了传统编码和 TDD 之间的差异。TDD与传统开发不同,如图1.9所示。您首先编写一个失败的测试;然后您继续创建生产代码,查看测试通过,并继续重构代码或创建另一个失败的测试。

Figures 1.8 and 1.9 show the differences between traditional coding and TDD. TDD is different from traditional development, as figure 1.9 shows. You begin by writing a test that fails; then you move on to creating the production code, seeing the test pass, and continuing on to either refactor your code or create another failing test.

01-08



图1.8 编写单元测试的传统方式

Figure 1.8 The traditional way of writing unit tests

 

 

01-09



图 1.9 测试驱动开发——鸟瞰图。请注意该过程的循环性质:编写测试、编写代码、重构、编写下一个测试。它显示了 TDD 的增量性质:小步骤可以充满信心地带来高质量的最终结果。

Figure 1.9 Test-driven development—a bird’s-eye view. Notice the circular nature of the process: write the test, write the code, refactor, write the next test. It shows the incremental nature of TDD: small steps lead to a quality end result with confidence.

本书重点关注编写良好单元测试的技术,而不是 TDD,但我是 TDD 的忠实粉丝。我使用 TDD 编写了多个主要应用程序和框架,管理过使用它的团队,并且教授了数百门有关 TDD 和单元测试技术的课程和研讨会。在我的整个职业生涯中,我发现 TDD 对于创建高质量代码、质量测试以及为我所编写的代码提供更好的设计很有帮助。我相信它可以为您带来好处,但它并不是没有代价的(学习时间、实施时间等等)。不过,如果您愿意接受学习的挑战,那么它绝对物有所值。

This book focuses on the technique of writing good unit tests, rather than on TDD, but I’m a big fan of TDD. I’ve written several major applications and frameworks using TDD, I’ve managed teams that utilize it, and I’ve taught hundreds of courses and workshops on TDD and unit testing techniques. Throughout my career, I’ve found TDD to be helpful in creating quality code, quality tests, and better designs for the code I was writing. I’m convinced that it can work to your benefit, but it’s not without a price (time to learn, time to implement, and more). It’s definitely worth the admission price, though, if you’re willing to take on the challenge of learning it.

1.10.1 TDD:不能替代良好的单元测试

1.10.1 TDD: Not a substitute for good unit tests

重要的是要认识到 TDD 并不能确保项目成功或测试稳健或可维护。人们很容易陷入 TDD 技术而不注意单元测试的编写方式:它们的命名、它们的可维护性或可读性,以及它们是否测试了正确的东西或者它们本身可能存在错误。这就是我写这本书的原因——因为编写好的测试是一项独立于 TDD 的技能。

It’s important to realize that TDD doesn’t ensure project success or tests that are robust or maintainable. It’s quite easy to get caught up in the technique of TDD and not pay attention to the way unit tests are written: their naming, how maintainable or readable they are, and whether they test the right things or might themselves have bugs. That’s why I’m writing this book—because writing good tests is a separate skill from TDD.

TDD 的技术非常简单:

The technique of TDD is quite simple:

  1. 编写失败的测试来证明最终产品中缺少代码或功能。测试的编写方式就好像生产代码已经可以工作一样,因此测试失败意味着生产代码中存在错误。我怎么知道?测试的编写方式是,如果生产代码没有错误,测试就会通过。

    在 JavaScript 以外的某些语言中,测试一开始甚至可能无法编译,因为代码尚不存在。一旦运行,它应该会失败,因为生产代码仍然无法工作。这就是测试驱动设计思维中的许多“设计”发生的地方。

  2. Write a failing test to prove code or functionality is missing from the end product. The test is written as if the production code were already working, so the test failing means there’s a bug in the production code. How do I know? The test is written such that it would pass if the production code had no bugs.

    In some languages other than JavaScript, the test might not even compile at first, since the code doesn’t exist yet. Once it does run, it should be failing, because the production code is still not working. This is where a lot of the “design” in test-driven-design thinking happens.

  3. 通过向生产代码添加满足测试期望的功能来使测试通过。生产代码应尽可能简单。不要碰测试。您必须仅通过触摸生产代码来使其通过。

  4. Make the test pass by adding functionality to the production code that meets the expectations of your test. The production code should be kept as simple as possible. Don’t touch the test. You have to make it pass only by touching production code.

  5. 重构你的代码。当测试通过时,您可以自由地继续进行下一个单元测试或重构代码(生产代码和测试)以使其更具可读性,消除代码重复等。这是“设计”部分发生的另一个点。我们重构甚至可以重新设计我们的组件,同时仍然保留旧功能。

    重构步骤应该非常小并且是增量的,并且我们在每个小步骤之后运行所有测试,以确保我们的更改不会破坏任何内容。重构可以在编写多个测试后或编写每个测试后进行。这是一个重要的实践,因为它可以确保您的代码更易于阅读和维护,同时仍然通过所有以前编写的测试。本书后面有一整节(8.3)是关于重构的。

  6. Refactor your code. When the test passes, you’re free to move on to the next unit test or to refactor your code (both production code and tests) to make it more readable, to remove code duplication, and so on. This is another point where the “design” part happens. We refactor and can even redesign our components while still keeping the old functionality.

    Refactoring steps should be very small and incremental, and we run all the tests after each small step to make sure we didn’t break anything with our changes. Refactoring can be done after writing several tests or after writing each test. It’s an important practice, because it ensures your code gets easier to read and maintain, while still passing all of the previously written tests. There’s a whole section (8.3) on refactoring later in the book.

定义 重构意味着改变一段代码而不改变其功能。如果您曾经重命名过一个方法,那么您就已经完成了重构。如果您曾经将一个大型方法拆分为多个较小的方法调用,那么您就已经重构了您的代码。代码仍然做同样的事情,但它变得更容易维护、阅读、调试和更改。

Definition Refactoring means changing a piece of code without changing its functionality. If you’ve ever renamed a method, you’ve done refactoring. If you’ve ever split a large method into multiple smaller method calls, you’ve refactored your code. The code still does the same thing, but it becomes easier to maintain, read, debug, and change.

前面的步骤听起来很技术性,但背后蕴藏着很多智慧。如果做得正确,TDD 可以使您的代码质量飙升,减少错误数量,提高您对代码的信心,缩短发现错误所需的时间,改进代码的设计,并使您的经理更高兴。如果 TDD 做得不正确,可能会导致项目进度延误、浪费时间、降低积极性并降低代码质量。这是一把双刃剑,很多人都经历了一番艰难才发现这一点。

The preceding steps sound technical, but there’s a lot of wisdom behind them. Done correctly, TDD can make your code quality soar, decrease the number of bugs, raise your confidence in the code, shorten the time it takes to find bugs, improve your code’s design, and keep your manager happier. If TDD is done incorrectly, it can cause your project schedule to slip, waste your time, lower your motivation, and lower your code quality. It’s a double-edged sword, and many people find this out the hard way.

从技术上讲,没有人告诉您的 TDD 最大好处之一是,通过看到测试失败,然后看到它在不更改测试的情况下通过,您基本上是在测试测试本身。如果您预计它会失败而它却通过了,那么您的测试中可能存在错误,或者您正在测试错误的东西。如果测试失败,你修复了它,现在你期望它通过,但它仍然失败,你的测试可能有错误,或者可能期望发生错误的事情。

Technically, one of the biggest benefits of TDD that nobody tells you about is that by seeing a test fail, and then seeing it pass without changing the test, you’re basically testing the test itself. If you expect it to fail and it passes, you might have a bug in your test or you’re testing the wrong thing. If the test failed, you fixed it, and now you expect it to pass, and it still fails, your test could have a bug, or maybe it’s expecting the wrong thing to happen.

本书讨论的是可读的、可维护的和值得信赖的测试,但是如果你在上面添加 TDD,你对自己的测试的信心将会增加,因为你会看到失败的地方,你修复了它,测试在应该失败的时候失败,在应该通过的时候通过。在后测试风格中,您通常只会看到它们在应该通过的时候通过,在不应该通过的时候失败(因为它们测试的代码应该已经可以工作)。时分双工对此有很大帮助,这也是开发人员在练习 TDD 时比事后进行单元测试时进行的调试要少得多的原因之一。如果他们信任这些测试,他们就不会觉得需要调试它“以防万一”。这种信任只有通过了解测试的双方才能获得——该失败的时候失败,该通过的时候通过。

This book deals with readable, maintainable, and trustworthy tests, but if you add TDD on top, your confidence in your own tests will increase by seeing the failed, you fixed it, tests failing when they should and passing when they should. In test-after style, you’ll usually only see them pass when they should, and fail when they shouldn’t (since the code they test should already be working). TDD helps with that a lot, and it’s also one of the reasons developers do far less debugging when practicing TDD than when they’re simply unit testing after the fact. If they trust the tests, they don’t feel a need to debug it “just in case.” That’s the kind of trust you can only gain by seeing both sides of the test—failing when it should and passing when it should.

1.10.2 成功 TDD 所需的三项核心技能

1.10.2 Three core skills needed for successful TDD

要在测试驱动开发中取得成功,您需要三种不同的技能:了解如何编写良好的测试、以测试为先编写测试以及良好地设计测试和生产代码。图 1.10 更清楚地显示了这些:

To be successful in test-driven development, you need three different skill sets: knowing how to write good tests, writing them test-first, and designing the tests and the production code well. Figure 1.10 shows these more clearly:

  • 仅仅因为您首先编写测试并不意味着它们是可维护的、可读的或值得信赖的。良好的单元测试技能是本书的全部内容。

  • Just because you write your tests first doesn’t mean they’re maintainable, readable, or trustworthy. Good unit testing skills are what this book is all about.

  • 仅仅因为您编写了可读、可维护的测试,并不意味着您将获得与先编写测试时相同的好处。大多数 TDD 书籍都教授测试优先的技能,但没有教授良好测试的技能。我特别推荐 Kent Beck 的《测试驱动开发:举例》(Addison-Wesley Professional,2002 年)。

  • Just because you write readable, maintainable tests doesn’t mean you’ll get the same benefits as when writing them test-first. Test-first skills are what most of the TDD books out there teach, without teaching the skills of good testing. I would especially recommend Kent Beck’s Test-Driven Development: By Example (Addison-Wesley Professional, 2002).

  • 仅仅因为您首先编写了测试,并且它们具有可读性和可维护性,并不意味着您最终会得到一个设计良好的系统。设计技巧使您的代码变得美观且可维护。我推荐Steve Freeman 和 Nat Pryce 编写的《Growing Object-Oriented Software, Guided by Tests》(Addison-Wesley Professional,2009 年)和Robert C. Martin 编写的《Clean Code》(Pearson,2008 年)作为有关该主题的好书。

  • Just because you write your tests first, and they’re readable and maintainable, doesn’t mean you’ll end up with a well-designed system. Design skills are what make your code beautiful and maintainable. I recommend Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce (Addison-Wesley Professional, 2009) and Clean Code by Robert C. Martin (Pearson, 2008) as good books on the subject.

01-10



图1.10 测试驱动开发的三大核心技能

Figure 1.10 Three core skills of test-driven development

学习 TDD 的实用方法是分别学习这三个方面;也就是说,一次专注于一项技能,同时忽略其他技能。我推荐这种方法的原因是,我经常看到人们试图同时学习所有三种技能,在这个过程中经历了一段非常艰难的时期,最后因为墙太高而放弃。通过采取更渐进的方法来学习这个领域,你就可以摆脱那种担心自己在与当前关注的领域不同的领域中犯错的持续恐惧。

A pragmatic approach to learning TDD is to learn each of these three aspects separately; that is, to focus on one skill at a time, ignoring the others in the meantime. The reason I recommend this approach is that I often see people trying to learn all three skill sets at the same time, having a really hard time in the process, and finally giving up because the wall is too high to climb. By taking a more incremental approach to learning this field, you relieve yourself of the constant fear that you’re getting it wrong in a different area than you’re currently focusing on.

在下一章中,您将开始使用 Jest(最常用的 JavaScript 测试框架之一)编写第一个单元测试。

In the next chapter, you’ll start writing your first unit tests using Jest, one of the most commonly used test frameworks for JavaScript.

概括

Summary

  • 一个好的单元测试具有以下品质:

    • 它应该运行得很快。

    • 它应该完全控制被测试的代码。

    • 它应该完全隔离(它应该独立于其他测试运行)。

    • 它应该在内存中运行,不需要文件系统文件、网络或数据库。

    • 它应该尽可能同步和线性(无并行线程)。

  • A good unit test has these qualities:

    • It should run quickly.

    • It should have full control of the code under test.

    • It should be fully isolated (it should run independently of other tests).

    • It should run in memory without requiring filesystem files, networks, or databases.

    • It should be as synchronous and linear as possible (no parallel threads).

  • 入口点是公共函数,是进入我们工作单元并触发底层逻辑的门户。退出点是您可以通过测试进行检查的地方。它们代表工作单元的效果。

  • Entry points are public functions that are the doorways into our units of work and trigger the underlying logic. Exit points are the places you can inspect with your test. They represent the effects of the units of work.

  • 退出点可以是返回值、状态更改或对第三方依赖项的调用。每个出口点通常需要单独的测试,并且每种类型的出口点需要不同的测试技术。

  • An exit point can be a return value, a change of state, or a call to a third-party dependency. Each exit point usually requires a separate test, and each type of exit point requires a different testing technique.

  • 工作单元是从调用入口点到通过一个或多个出口点产生明显的最终结果之间发生的操作的总和。一个工作单元可以跨越一个功能、一个模块或多个模块。

  • A unit of work is the sum of actions that take place between the invocation of an entry point up until a noticeable end result through one or more exit points. A unit of work can span a function, a module, or multiple modules.

  • 集成测试只是单元测试,其中部分或全部依赖项是真实的并且位于当前执行过程之外。相反,单元测试就像集成测试,但所有依赖项都在内存中(真实的和虚假的),并且我们可以控制它们在测试中的行为。

  • Integration testing is just unit testing with some or all of the dependencies being real and residing outside of the current execution process. Conversely, unit testing is like integration testing, but with all of the dependencies in memory (both real and fake), and we have control over their behavior in the test.

  • 任何测试最重要的属性是可读性、可维护性和可信性。可读性告诉我们阅读和理解测试的难易程度。可维护性衡量的是维护测试代码的痛苦程度。如果没有信任,就很难在代码库中引入重要的更改(例如重构),从而导致代码恶化。

  • The most important attributes of any test are readability, maintainability, and trust. Readability tells us how easy it is to read and understand the test. Maintainability is the measure of how painful it is to maintain the test code. Without trust, it’s harder to introduce important changes (such as refactoring) in a codebase, which leads to code deterioration.

  • 测试驱动开发(TDD)是一种提倡在生产代码之前编写测试的技术。这种方法也称为测试优先方法(与代码优先相对)。

  • Test-driven development (TDD) is a technique that advocates for writing tests before the production code. This approach is also referred to as a test-first approach (as opposed to code-first).

  • TDD 的主要好处是验证测试的正确性。在编写生产代码之前看到测试失败,可以确保如果它们所涵盖的功能停止正常工作,这些相同的测试也会失败。

  • The main benefit of TDD is verifying the correctness of your tests. Seeing your tests fail before writing production code ensures that these same tests would fail if the functionality they cover stops working properly.

2 第一个单元测试

2 A first unit test

本章涵盖

This chapter covers

  • 使用 Jest 编写您的第一个测试
  • Writing your first test with Jest
  • 测试结构和命名约定
  • Test structure and naming conventions
  • 使用断言库
  • Working with the assertion library
  • 重构测试并减少重复代码
  • Refactoring tests and reducing repetitive code

当我第一次开始使用真正的单元测试框架编写单元测试时,几乎没有文档,而且我使用的框架没有适当的示例。(我主要使用 VB 5 进行编码和当时的6个。)学习与他们一起工作是一个挑战,我开始编写相当糟糕的测试。幸运的是,时代变了。在 JavaScript 以及几乎任何语言中,社区提供了广泛的选择、大量的文档和支持来尝试这些有用的包。

When I first started writing unit tests with a real unit testing framework, there was little documentation, and the frameworks I worked with didn’t have proper examples. (I was mostly coding in VB 5 and 6 at the time.) It was a challenge learning to work with them, and I started out writing rather poor tests. Fortunately, times have changed. In JavaScript, and in practically any language out there, there’s a wide range of choices and plenty of documentation and support from the community for trying out these bundles of helpfulness.

在上一章中,我们编写了一个非常简单的自制测试框架。在本章中,我们将了解 Jest,它将成为我们为本书选择的框架。

In the previous chapter, we wrote a very simple home-grown test framework. In this chapter, we’ll take a look at Jest, which will be our framework of choice for this book.

2.1 笑话介绍

2.1 Introducing Jest

Jest 是 Facebook 创建的开源测试框架。它易于使用、易于记忆,并且具有许多出色的功能。Jest 最初是为了测试 JavaScript 中的前端 React 组件而创建的。如今,它广泛应用于行业的许多领域,用于后端和前端项目测试。它支持两种主要的测试语法(一种使用该词test,另一种基于 Jasmin 语法,Jasmin 语法是一个启发了 Jest 的许多功能的框架)。我们将尝试两者,看看我们更喜欢哪一个。

Jest is an open source test framework created by Facebook. It’s easy to use, easy to remember, and has lots of great features. Jest was originally created for testing frontend React components in JavaScript. These days it’s widely used in many parts of the industry for both backend and frontend project testing. It supports two major flavors of test syntax (one that uses the word test and another that’s based on the Jasmin syntax, a framework that has inspired many of Jest’s features). We’ll try both of them to see which one we like better.

除了 Jest 之外,JavaScript 中还有许多其他测试框架,而且几乎都是开源的。它们之间在风格和 API 方面存在一些差异,但就本书的目的而言,这应该不会太重要。

Aside from Jest, there are many other testing frameworks in JavaScript, pretty much all open source as well. There are some differences between them in style and APIs, but for the purposes of this book, that shouldn’t matter too much.

2.1.1 准备我们的环境

2.1.1 Preparing our environment

确保本地安装了 Node.js。您可以按照https://nodejs.org/en/download/上的说明将其启动并在您的计算机上运行。该站点将为您提供长期支持 (LTS) 版本或当前版本的选项。LTS 版本面向企业,而当前版本的更新更加频繁。两者都适用于本书的目的。

Make sure you have Node.js installed locally. You can follow the instructions at https://nodejs.org/en/download/ to get it up and running on your machine. The site will provide you with the option of either a long-term support (LTS) release or a current release. The LTS release is geared toward enterprises, whereas the current release has more frequent updates. Either will work for the purposes of this book.

确保您的计算机上安装了节点包管理器 (npm)。它包含在 Node.js 中,因此npm -v在命令行上运行该命令,如果您看到 6.10.2 或更高版本,则应该可以开始使用。如果没有,请确保已安装 Node.js。

Make sure that the node package manager (npm) is installed on your machine. It is included with Node.js, so run the command npm -v on the command line, and if you see a version of 6.10.2 or higher, you should be good to go. If not, make sure Node.js is installed.

2.1.2 准备我们的工作文件夹

2.1.2 Preparing our working folder

开始使用 Jest,让我们创建一个名为“ch2”的新空文件夹,并使用您选择的包管理器对其进行初始化。我将使用 npm,因为我必须选择一个。Yarn 是另一种包管理器。就本书而言,您使用哪一个并不重要。

To get started with Jest, let’s create a new empty folder named “ch2” and initialize it with a package manager of your choice. I’ll use npm, since I have to choose one. Yarn is an alternative package manager. It shouldn’t matter, for the purposes of this book, which one you use.

Jest 需要 jest.config.js 或 package.json 文件。我们选择后者,并将npm init为我们生成一个:

Jest expects either a jest.config.js or a package.json file. We’re going with the latter, and npm init will generate one for us:

mkdir ch2
光盘频道2
npm 初始化——是的
//或者
纱线初始化-是
git初始化
mkdir ch2
cd ch2
npm init --yes
//or
yarn init -yes 
git init

我还在这个文件夹中初始化 Git。无论如何,建议您这样做,以跟踪更改,但对于 Jest 来说,此文件在幕后用于跟踪文件更改并运行特定测试。这让 Jest 的生活变得更轻松。

I’m also initializing Git in this folder. This would be recommended anyway, to track changes, but for Jest this file is used under the covers to track changes to files and run specific tests. It makes Jest’s life easier.

默认情况下,Jest 将在由此命令创建的 package.json 文件或特殊的 jest.config.js 文件中查找其配置。目前,除了默认的 package.json 文件之外,我们不需要任何东西。如果您想了解有关 Jest 配置选项的更多信息,请参阅https://jestjs.io/docs/en/configuration

By default, Jest will look for its configuration either in the package.json file that is created by this command or in a special jest.config.js file. For now, we won’t need anything but the default package.json file. If you’d like to learn more about the Jest configuration options, refer to https://jestjs.io/docs/en/configuration.

2.1.3 安装 Jest

2.1.3 Installing Jest

接下来,我们将安装 Jest。要将 Jest 安装为开发依赖项(这意味着它不会分发到生产环境),我们可以使用以下命令:

Next, we’ll install Jest. To install Jest as a dev dependency (which means it does not get distributed to production) we can use this command:

npm install --save-dev 玩笑
//或者
纱线添加笑话-dev
npm install --save-dev jest
//or
yarn add jest -dev

这将在我们的[根文件夹]/node_modules/bin 下创建一个新的 jest.js 文件。然后我们可以使用该npx jest命令执行 Jest。

This will create a new jest.js file under our [root folder]/node_modules/bin. We can then execute Jest using the npx jest command.

我们还可以通过执行以下命令在本地计算机上全局save-dev安装 Jest(我建议在安装的基础上执行此操作):

We can also install Jest globally on the local machine (I recommend doing this on top of the save-dev installation) by executing this command:

npm install -g 开玩笑
npm install -g jest

这将使我们可以自由地jest在任何具有测试的文件夹中直接从命令行执行命令,而无需通过 npm 来执行它。

This will give us the freedom to execute the jest command directly from the command line in any folder that has tests, without going through npm to execute it.

在实际项目中,通常使用npm命令来运行测试而不是使用全局jest. 我将在接下来的几页中展示这是如何完成的

In real projects, it is common to use npm commands to run tests instead of using the global jest. I’ll show how this is done in the next few pages.

2.1.4 创建测试文件

2.1.4 Creating a test file

Jest 有几种默认方法来查找测试文件:

Jest has a couple of default ways to find test files:

  • 如果存在 __tests__ 文件夹,它将加载其中的所有文件作为测试文件,无论其命名约定如何。

  • If there’s a __tests__ folder, it loads all the files in it as test files, regardless of their naming conventions.

  • 它尝试递归地在项目根文件夹下的任何文件夹中查找以 *.spec.js 或 *.test.js 结尾的任何文件。

  • It tries to find any file that ends with *.spec.js or *.test.js, in any folder under the root folder of your project, recursively.

我们将使用第一个变体,但我们也会使用 *test.js 或 *.spec.js 来命名我们的文件,以使事情更加一致,以防我们稍后想要移动它们(并停止使用 __tests_文件夹)。

We’ll use the first variation, but we’ll also name our files with either *test.js or *.spec.js to make things a bit more consistent in case we want to move them around later (and stop using the __tests_ folder altogether).

您还可以根据自己的喜好配置 Jest,使用 jest.config.js 文件或通过 package.json 指定如何查找哪些文件。您可以在https://jestjs.io/docs/en/configuration查找 Jest 文档以查找所有血淋淋的细节。

You can also configure Jest to your heart’s content, specifying how to find which files where, with a jest.config.js file or through package.json. You can look up the Jest docs at https://jestjs.io/docs/en/configuration to find all the gory details.

下一步是在 ch2 文件夹下创建一个名为 __tests__ 的特殊文件夹。在此文件夹下,创建一个以 test.js 或 spec.js 结尾的文件,例如 my-component.test.js。您选择哪个后缀取决于您自己——这取决于您自己的风格。我将在本书中互换使用它们,因为我认为“测试”是“规范”的最简单版本,因此在展示非常简单的东西时我会使用它。

The next step is to create a special folder under our ch2 folder called __tests__. Under this folder, create a file that ends with either test.js or spec.js—my-component.test.js, for example. Which suffix you choose is up to you—it’s about your own style. I’ll use them interchangeably in this book because I think of “test” as the simplest version of “spec,” so I use it when showing very simple things.

测试文件位置

Test file locations

我发现放置测试文件有两种主要模式: 有些人喜欢将测试文件直接放置在正在测试的文件或模块旁边。其他人更喜欢将所有文件放在测试目录下。您选择哪种方法并不重要;重要的是。只需在整个项目中保持一致的选择,这样就很容易知道在哪里可以找到特定项目的测试。

There are two main patterns I see for placing test files: Some people prefer to place the test files directly next to the files or modules being tested. Others prefer to place all the files under a test directory. Which approach you choose doesn’t really matter; just be consistent in your choice throughout a project, so it’s easy to know where to find the tests for a specific item.

我发现将测试放在测试文件夹中还可以将帮助程序文件放在靠近测试的测试文件夹下。至于在测试和被测代码之间轻松导航,现在大多数 IDE 都有插件,允许您使用键盘快捷键在代码和测试之间导航。

I find that placing tests in a test folder allows me to also put helper files under the test folder close to the tests. As for easily navigating between tests and the code under test, there are plugins for most IDEs today that allow you to navigate between code and its tests with a keyboard shortcut.

我们不需要require()从文件顶部开始使用 Jest。它会自动导入全局函数供我们使用。您应该感兴趣的主要函数包括testdescribeitexpect。清单 2.1 显示了一个简单的测试可能是什么样子。

We don’t need require() at the top of the file to start using Jest. It automatically imports global functions for us to use. The main functions you should be interested in include test, describe, it, and expect. Listing 2.1 shows what a simple test might look like.

清单 2.1 你好,笑话

Listing 2.1 Hello Jest

test ('hello jest', () => {
     Expect ('hello').toEqual('再见');
});
test('hello jest', () => {
    expect('hello').toEqual('goodbye');
});

我们还没有使用describeit,但我们很快就会使用它们。

We haven’t used describe and it yet, but we’ll get to them soon.

2.1.5 执行 Jest

2.1.5 Executing Jest

要运行此测试,我们需要能够执行 Jest。为了从命令行识别 Jest,我们需要执行以下操作之一:

To run this test, we need to be able to execute Jest. For Jest to be recognized from the command line, we need to do either of the following:

  • 通过运行在计算机上全局安装 Jest npm install jest -g

  • Install Jest globally on the machine by running npm install jest -g.

  • 用于通过键入ch2 文件夹的根目录npx来从 node_modules 目录执行 Jest 。jest

  • Use npx to execute Jest from the node_modules directory by typing jest in the root of the ch2 folder.

如果所有星星都正确排列,您应该会看到 Jest 测试运行的结果和失败。你的第一次失败。耶!图 2.1 显示了运行该命令时终端上的输出。看到测试工具如此可爱、丰富多彩(如果您正在阅读电子书)、有用的输出,真是太酷了。如果您的终端处于深色模式,它看起来会更酷。

If all the stars lined up correctly, you should see the results of the Jest test run and a failure. Your first failure. Yay! Figure 2.1 shows the output on my terminal when I run the command. It’s pretty cool to see such lovely, colorful (if you’re reading the e-book), useful output from a test tool. It looks even cooler if your terminal is in dark mode.

02-01



图 2.1 Jest 的终端输出

Figure 2.1 Terminal output from Jest

让我们仔细看看细节。图 2.2 显示了相同的输出,但后面带有数字。让我们看看这里提供了多少条信息:

Let’s take a closer look at the details. Figure 2.2 shows the same output, but with numbers to follow along. Let’s see how many pieces of information are presented here:

所有失败测试(带有名称)的快速列表,旁边有漂亮的红色 X

A quick list of all the failing tests (with names) with nice red Xs next to them

关于失败的期望的详细报告(又名我们的断言)

A detailed report on the expectation that failed (aka our assertion)

实际值与期望值的准确差异

The exact difference between the actual value and expected value

执行的比较类型

The type of comparison that was executed

测试代码

The code for the test

测试失败的确切行(视觉上)

The exact line (visually) where the test failed

有关运行、失败和通过的测试数量的报告

A report of how many tests ran, failed, and passed

所花费的时间

The time it took

快照数量(与我们的讨论无关)

The number of snapshots (not relevant to our discussion)

02-02



图 2.2 Jest 带注释的终端输出

Figure 2.2 Annotated terminal output from Jest

想象一下尝试自己编写所有这些报告功能。有可能,但是谁有时间和意愿呢?另外,您还必须处理报告机制中的任何错误。

Imagine trying to write all this reporting functionality yourself. It’s possible, but who’s got the time and the inclination? Plus, you’d have to take care of any bugs in the reporting mechanism.

如果我们在测试中更改goodbye为,我们可以看到测试通过后会发生什么(图2.3)。hello漂亮又绿色,一切都应该如此(同样,在数字版本中,否则它是漂亮的灰色)。

If we change goodbye to hello in the test, we can see what happens when the test passes (figure 2.3). Nice and green, as all things should be (again, in the digital version—otherwise it’s nice and grey).

02-03



图 2.3 通过测试的 Jest 终端输出

Figure 2.3 Jest terminal output for a passing test

您可能会注意到,运行此单个 Hello World 测试需要 1.5 秒。如果我们使用该命令jest --watch,我们可以让 Jest 监视文件夹中的文件系统活动,并自动对已更改的文件运行测试,而无需每次都重新初始化。这可以节省大量时间,并且对持续测试的整个概念确实有帮助。在工作站的另一个窗口中设置一个终端jest --watch,您可以继续编码并获得有关您可能创建的问题的快速反馈。这是进入事物流程的好方法。

You might note that it takes 1.5 seconds to run this single Hello World test. If we used the command jest --watch instead, we could have Jest monitor filesystem activity in our folder and automatically run tests for files that have changed without re-initializing itself every time. This can save a considerable amount of time, and it really helps with the whole notion of continuous testing. Set a terminal in the other window of your workstation with jest --watch on it, and you can keep coding and getting fast feedback on issues you might be creating. That’s a good way to get into the flow of things.

Jest 还支持异步风格的测试和回调。当我们在本书后面讨论这些主题时,我将触及这些内容,但如果您现在想了解有关这种风格的更多信息,请转到有关该主题的 Jest 文档:https: //jestjs.io/docs /en/异步.

Jest also supports async-style testing and callbacks. I’ll touch on these when we get to those topics later in the book, but if you’d like to learn more about this style now, head over to the Jest documentation on the subject: https://jestjs.io/docs/en/asynchronous.

2.2 库、断言、运行器和报告器

2.2 The library, the assert, the runner, and the reporter

Jest 为我们发挥了多种作用:

Jest has acted in several capacities for us:

  • 它充当编写测试时使用的测试库。

  • It acted as a test library to use when writing the test.

  • 它充当断言库,用于在测试 ( expect) 内进行断言。

  • It acted as an assertion library for asserting inside the test (expect).

  • 它充当测试 运行程序

  • It acted as the test runner.

  • 它充当测试运行的测试 报告者。

  • It acted as the test reporter for the test run.

Jest 还提供隔离设施来创建模拟、桩和间谍,尽管我们还没有看到。我们将在后面的章节中讨论这些想法。

Jest also provides isolation facilities to create mocks, stubs, and spies, though we haven’t seen that yet. We’ll touch on these ideas in later chapters.

除了隔离设施之外,在其他语言中,测试框架扮演我刚才提到的所有角色(库、断言、测试运行程序和测试报告程序)是很常见的,但 JavaScript 世界似乎更加分散。许多其他测试框架仅提供其中一些功能。也许是因为“只做一件事,并把它做好”的口号被牢记在心,也许还有其他原因。无论如何,Jest 作为少数一体化框架之一脱颖而出。JavaScript 开源文化的力量证明了,对于每一类,都有多种工具可供您混合搭配来创建您自己的超级工具集。

Other than isolation facilities, it’s very common in other languages for a test framework to fill all the roles I just mentioned—library, assertions, test runner, and test reporter—but the JavaScript world seems a bit more fragmented. Many other test frameworks provide only some of these facilities. Perhaps this is because the mantra of “do one thing, and do it well” has been taken to heart, or perhaps it’s for other reasons. In any case, Jest stands out as one of a handful of all-in-one frameworks. It is a testament to the strength of the open source culture in JavaScript that for each one of these categories, there are multiple tools that you can mix and match to create your own super toolset.

我为这本书选择 Jest 的原因之一是这样我们就不必过多地担心工具或处理缺失的功能——我们可以只关注模式。这样我们就不必在一本主要关注模式和反模式的书中使用多个框架。

One of the reasons I chose Jest for this book is so we don’t have to bother too much with the tooling or deal with missing features—we can just focus on the patterns. That way we won’t have to use multiple frameworks in a book that is mostly concerned with patterns and antipatterns.

2.3 单元测试框架提供什么

2.3 What unit testing frameworks offer

让我们缩小一下看看我们在哪里。像 Jest 这样的框架比创建我们自己的框架(就像我们在上一章中开始做的那样)或手动测试东西能给我们提供什么?

Let’s zoom out for a second and see where we are. What do frameworks like Jest offer us over creating our own framework, like we started to do in the previous chapter, or over manually testing things?

  • 结构——当您使用测试框架时,您不必每次想要测试某个功能时都重新发明轮子,而是总是以相同的方式开始——编写一个具有明确定义的结构的测试,让每个人都可以轻松识别、阅读和理解。

  • Structure—Instead of reinventing the wheel every time you want to test a feature, when you use a test framework you always start out the same way—by writing a test with a well-defined structure that everyone can easily recognize, read, and understand.

  • 可重复性——使用测试框架时,很容易重复编写新测试的行为。使用测试运行器重复执行测试也很容易,并且每天可以多次快速地执行此操作。理解失败及其原因也很容易。有人已经为我们完成了所有艰苦的工作,而不是我们必须将所有这些东西编码到我们手写的框架中。

  • Repeatability—When using a test framework, it’s easy to repeat the act of writing a new test. It’s also easy to repeat the execution of the test, using a test runner, and it’s easy to do this quickly and many times a day. It’s also easy to understand failures and their causes. Someone has already done all the hard work for us, instead of us having to code all that stuff into our hand-rolled framework.

  • 信心和节省时间——当我们推出自己的测试框架时,该框架更有可能存在错误,因为它比现有的成熟且广泛使用的框架更少经过实战测试。另一方面,手动测试通常非常耗时。当我们时间紧迫时,我们可能会专注于测试那些感觉最关键的事情,并跳过那些可能感觉不那么重要的事情。我们可以跳过小但重要的错误。通过使编写新测试变得容易,我们更有可能为那些感觉不那么重要的东西编写测试,因为我们不会花太多时间为大的东西编写测试。

  • Confidence and time savings—When we roll our own test framework, the framework is more likely to have bugs in it, since it is less battle-tested than an existing mature and widely used framework. On the other hand, manually testing things is usually very time consuming. When we’re short on time, we’ll likely focus on testing the things that feel the most critical and skip over things that might feel less important. We could be skipping small but significant bugs. By making it easy to write new tests, it’s more likely that we’ll also write tests for the stuff that feels less significant because we won’t be spending too much time writing tests for the big stuff.

  • 共享理解——框架的报告有助于管理团队级别的任务(当测试通过时,意味着任务已完成)。有些人发现这很有用。

  • Shared understanding—The framework’s reporting can be helpful for managing tasks at the team level (when a test is passing, it means the task is done). Some people find this useful.

简而言之,用于编写、运行和审查单元测试及其结果的框架可以对愿意投入时间学习如何正确使用它们的开发人员的日常生活产生巨大的影响。图 2.4 显示了单元测试框架及其辅助工具在软件开发中具有影响力的领域,表 2.1 列出了我们通常使用测试框架执行的操作类型。

In short, frameworks for writing, running, and reviewing unit tests and their results can make a huge difference in the daily lives of developers who are willing to invest the time in learning how to use them properly. Figure 2.4 shows the areas in software development in which a unit testing framework and its helper tools have influence, and table 2.1 lists the types of actions we usually execute with a test framework.

02-04



图 2.4 单元测试使用单元测试框架中的库编写为代码。测试从 IDE 内的测试运行程序或通过命令行运行,并且开发人员或自动构建过程通过测试报告器(作为输出文本或在 IDE 中)审查结果。

Figure 2.4 Unit tests are written as code, using libraries from the unit testing framework. The tests are run from a test runner inside the IDE or through the command line, and the results are reviewed through a test reporter (either as output text or in the IDE) by the developer or an automated build process.

表 2.1 测试框架如何帮助开发人员编写和执行测试并审查结果

Table 2.1 How testing frameworks help developers write and execute tests and review results

单元测试实践

Unit testing practice

该框架如何提供帮助

How the framework helps

以结构化方式轻松编写测试。

Write tests easily and in a structured manner.

框架为开发人员提供辅助函数、断言函数和结构相关函数。

A framework supplies the developer with helper functions, assertion functions, and structure-related functions.

执行一项或全部单元测试。

Execute one or all of the unit tests.

框架提供了一个测试运行器,通常在命令行上,

A framework provides a test runner, usually at the command line, that

  1. 识别代码中的测试

  2. Identifies tests in your code

  3. 自动运行测试

  4. Runs tests automatically

  5. 运行时指示测试状态

  6. Indicates test status while running

查看测试运行的结果。

Review the results of the test runs.

测试运行者通常会提供以下信息:

A test runner will usually provide information such as

  1. 运行了多少测试

  2. How many tests ran

  3. 有多少测试未运行

  4. How many tests didn’t run

  5. 有多少测试失败

  6. How many tests failed

  7. 哪些测试失败了

  8. Which tests failed

  9. 测试失败的原因

  10. The reason tests failed

  11. 失败的代码位置

  12. The code location that failed

  13. 可能为导致测试失败的任何异常提供完整的堆栈跟踪,并让您转到调用堆栈内的各种方法调用

  14. Possibly provide a full stack trace for any exceptions that caused the test to fail, and let you go to the various method calls inside the call stack

在撰写本文时,大约有 900 个单元测试框架,其中有两个以上适用于大多数公共使用的编程语言(还有一些已失效的框架)。您可以在维基百科上找到一个很好的列表:https ://en.wikipedia.org/wiki/List_of_unit_testing_frameworks 。

At the time of writing, there are around 900 unit testing frameworks out there, with more than a couple for most programming languages in public use (and a few dead ones). You can find a good list on Wikipedia: https://en.wikipedia.org/wiki/List_of_unit_testing_frameworks.

注意使用单元测试框架并不能确保您编写的测试是可读的可维护的值得信赖的,或者它们涵盖了您想要测试的所有逻辑。我们将在第 7 章到第 9 章以及本书的其他各个地方讨论如何确保您的单元测试具有这些属性。

Note Using a unit testing framework doesn’t ensure that the tests you write are readable, maintainable, or trustworthy, or that they cover all the logic you’d like to test. We’ll look at how to ensure your unit tests have these properties in chapters 7 through 9 and in various other places throughout this book.

2.3.1 xUnit 框架

2.3.1 The xUnit frameworks

当我开始编写测试时(在 Visual Basic 时代),衡量大多数单元测试框架的标准统称为 xUnit。xUnit 框架思想的鼻祖是 SUnit,Smalltalk 的单元测试框架。

When I started writing tests (in the Visual Basic days), the standard by which most unit test frameworks were measured was collectively called xUnit. The grandfather of the xUnit frameworks idea was SUnit, the unit testing framework for Smalltalk.

这些单元测试框架的名称通常以其构建语言的首字母开头;您可能有用于 C++ 的 CppUnit、用于 Java 的 JUnit、用于 .NET 的 NUnit 和 xUnit 以及用于 Haskell 编程语言的 HUnit。并非所有这些都遵循这些命名准则,但大多数都遵循。

These unit testing frameworks’ names usually start with the first letters of the language for which they were built; you might have CppUnit for C++, JUnit for Java, NUnit and xUnit for .NET, and HUnit for the Haskell programming language. Not all of them follow these naming guidelines, but most do.

2.3.2 xUnit、TAP 和 Jest 结构

2.3.2 xUnit, TAP, and Jest structures

不仅仅是名称相当一致。如果您使用的是 xUnit 框架,您还可以期望构建测试的特定结构。当这些框架运行时,它们将以相同的结构输出结果,通常是具有特定模式的 XML 文件。

It’s not just the names that were reasonably consistent. If you were using an xUnit framework, you could also expect a specific structure in which the tests were built. When these frameworks would run, they would output their results in the same structure, which was usually an XML file with a specific schema.

这种类型的 xUnit XML 报告至今仍然很流行,并且广泛应用于大多数构建工具,例如 Jenkins,它通过本机插件支持这种格式,并使用它来报告测试运行的结果。大多数静态语言的单元测试框架仍然使用 xUnit 模型进行结构,这意味着一旦您学会使用其中一种,您应该能够轻松使用它们中的任何一种(假设您了解特定的编程语言)。

This type of xUnit XML report is still prevalent today, and it’s widely used in most build tools, such as Jenkins, which support this format with native plugins and use it to report the results of test runs. Most unit test frameworks in static languages still use the xUnit model for structure, which means that once you’ve learned to use one of them, you should be able to easily use any of them (assuming you know the particular programming language).

测试结果报告结构的另一个有趣的标准称为TAP,即测试任何协议。TAP 最初是 Perl 测试工具的一部分,但现在它可以用 C、C++、Python、PHP、Perl、Java、JavaScript 和其他语言实现。TAP 不仅仅是一个报告规范。在 JavaScript 世界中,TAP 框架是最著名的原生支持 TAP 协议的测试框架。

The other interesting standard for the reporting structure of test results and more is called TAP, the Test Anything Protocol. TAP started life as part of the test harness for Perl, but now it has implementations in C, C++, Python, PHP, Perl, Java, JavaScript, and other languages. TAP is much more than just a reporting specification. In the JavaScript world, the TAP framework is the best-known test framework that natively supports the TAP protocol.

严格来说,Jest 并不是 xUnit 或 TAP 框架。默认情况下,其输出不兼容 xUnit 或 TAP。然而,由于 xUnit 风格的报告仍然统治着构建领域,因此我们通常希望在构建服务器上的报告中采用该协议。要获得大多数构建工具都能轻松识别的 Jest 测试结果,您可以安装 npm 模块,例如jest-xunit(如果您想要特定于 TAP 的输出,请使用jest-tap-reporter),然后在项目中使用特殊的 jest.config.js 文件来配置 Jest改变其报告格式。

Jest is not strictly an xUnit or TAP framework. Its output is not xUnit- or TAP-compliant by default. However, because xUnit-style reporting still rules the build sphere, we’ll usually want to adapt to that protocol for our reporting on a build server. To get Jest test results that are easily recognized by most build tools, you can install npm modules such as jest-xunit (if you want TAP-specific output, use jest-tap-reporter) and then use a special jest.config.js file in your project to configure Jest to alter its reporting format.

现在让我们继续写一些感觉更像是 Jest 的真实测试的东西,好吗?

Now let’s move on and write something that feels a bit more like a real test with Jest, shall we?

2.4 密码验证器项目介绍

2.4 Introducing the Password Verifier project

我们主要用于测试本书中的示例的项目一开始很简单,仅包含一个函数。随着本书的进展,我们将使用新功能、模块和类来扩展该项目,以演示单元测试的不同方面。我们将其称为密码验证器项目。

The project that we’ll mostly use for testing examples in this book will start out simple, containing only one function. As the book moves along, we’ll extend that project with new features, modules, and classes to demonstrate different aspects of unit testing. We’ll call it the Password Verifier project.

第一个场景非常简单。我们将构建一个密码验证库,它首先只是一个函数。函数verifyPassword(rules)允许我们放入名为 的自定义验证函数rules,并根据输入的规则输出错误列表。每个规则函数将输出两个字段:

The first scenario is pretty simple. We’ll be building a password verification library, and it will just be a function at first. The function, verifyPassword(rules), allows us to put in custom verification functions dubbed rules, and it outputs the list of errors, according to the rules that have been input. Each rule function will output two fields:

{
    通过:(布尔值),
    原因:(字符串)
}
{
    passed: (boolean),
    reason: (string)
} 

在本书中,我将教您编写测试,以便verifyPassword在我们向其添加更多功能时以多种方式检查其功能。

In this book, I’ll teach you to write tests that check verifyPassword’s functionality in multiple ways as we add more features to it.

下面的清单显示了该函数的版本 0,其实现非常简单。

The following listing shows version 0 of this function, with a very naive implementation.

清单 2.2 密码验证器版本 0

Listing 2.2 Password Verifier version 0

const verifyPassword = (输入, 规则) => {
  常量错误=[];
  规则.forEach(规则=> {
    const 结果 = 规则(输入);
    if (!result.passed) {
      error.push(`错误${result.reason}`);
    }
  });
  返回错误;
};
const verifyPassword = (input, rules) => {
  const errors = [];
  rules.forEach(rule => {
    const result = rule(input);
    if (!result.passed) {
      errors.push(`error ${result.reason}`);
    }
  });
  return errors;
};

当然,这不是最实用的代码,我们可能会稍后重构它,但我想让这里的事情保持非常简单,这样我们就可以专注于测试。

Granted, this is not the most functional-style code, and we might refactor it a bit later, but I wanted to keep things very simple here so we can focus on the tests.

该功能实际上并没有做太多事情。它迭代给定的所有规则,并使用提供的输入运行每一个规则。如果规则的结果未通过,则会将错误添加到作为最终结果返回的最终错误数组中。

The function doesn’t really do much. It iterates over all the rules given and runs each one with the supplied input. If the rule’s result is not passed, then an error is added to the final errors array that is returned as the final result.

2.5 verifyPassword 的第一个 Jest 测试

2.5 The first Jest test for verifyPassword

假设您已经安装了 Jest,您可以继续在 __tests__ 文件夹下创建一个名为password-verifier0.spec.js 的新文件。

Assuming you have Jest installed, you can go ahead and create a new file named password-verifier0.spec.js under the __tests__ folder.

使用 __tests__ 文件夹只是组织测试的一种约定,它是 Jest 默认配置的一部分。许多人喜欢将测试文件与正在测试的代码放在一起。每种方法都有优点和缺点,我们将在本书的后面部分进行讨论。现在,我们将使用默认值。

Using the __tests__ folder is only one convention for organizing your tests, and it’s part of Jest’s default configuration. There are many who prefer to place the test files alongside the code being tested. There are pros and cons to each approach, and we’ll get into that in later parts of the book. For now, we’ll go with the defaults.

这是针对我们的新功能的测试的第一个版本。

Here’s a first version of a test against our new function.

清单 2.3 第一个测试verifyPassword()

Listing 2.3 The first test against verifyPassword()

test('命名错误的测试', () => {
  const fakeRule = input =>                                   
    ({ pass: false, Reason: '假原因' });             
 
  const error = verifyPassword('任何值', [fakeRule]);   
 
  Expect(errors[0]).toMatch('假原因');                  
});
test('badly named test', () => {
  const fakeRule = input =>                                 
    ({ passed: false, reason: 'fake reason' });             
 
  const errors = verifyPassword('any value', [fakeRule]);   
 
  expect(errors[0]).toMatch('fake reason');                 
});

设置测试输入

Setting up inputs for the test

使用输入调用入口点

Invoking the entry point with the inputs

检查出口点

Checking the exit point

2.5.1 安排-执行-断言模式

2.5.1 The Arrange-Act-Assert pattern

清单 2.3 中的测试结构通俗地称为Arrange-Act-Assert (AAA) 模式。相当不错!我发现通过说“‘安排’部分太复杂”或“‘行动’部分在哪里?”之类的话来推理测试的各个部分非常容易。

The structure of the test in listing 2.3 is colloquially called the Arrange-Act-Assert (AAA) pattern. It’s quite nice! I find it very easy to reason about the parts of a test by saying things like “that ‘arrange’ part is too complicated” or “where is the ‘act’ part?”

在安排部分,我们创建了一个总是返回 false 的假规则,以便我们可以通过在测试结束时断言其原因来证明它确实被使用。verifyPassword然后我们将其与简单的输入一起发送。我们在断言部分检查我们得到的第一个错误是否与我们在排列部分给出的虚假原因匹配。.toMatch(/string/)使用正则表达式来查找字符串的一部分。这与使用相同.toContain('fake reason')

In the arrange part, we’re creating a fake rule that always returns false, so that we can prove it’s actually used by asserting on its reason at the end of the test. We then send it to verifyPassword along with a simple input. We check in the assert section that the first error we get matches the fake reason we gave in the arrange part. .toMatch(/string/) uses a regular expression to find a part of the string. It’s the same as using .toContain('fake reason').

在编写测试或修复某些内容后手动运行 Jest 很乏味,因此让我们配置 npm 以自动运行 Jest。进入ch2根文件夹下的package.json,在该scriptsitem下添加以下内容:

It’s tedious to run Jest manually after we write a test or fix something, so let’s configure npm to run Jest automatically. Go to package.json in the root folder of ch2 and add the following items under the scripts item:

"scripts": {
    "test": "jest", 
   "testw": "jest --watch" //如果不使用 git,则改为 --watchAll 
},
"scripts": {
   "test": "jest",
   "testw": "jest --watch" //if not using git, change to --watchAll
},

如果您没有在此文件夹中初始化 Git,则可以使用该命令--watchAll而不是--watch.

If you don’t have Git initialized in this folder, you can use the command --watchAll instead of --watch.

如果一切顺利,您现在可以npm test从 ch2 文件夹中输入命令行,Jest 将运行一次测试。如果您输入npm run testw,Jest 将运行并无限循环地等待更改,直到您使用 Ctrl-C 终止该进程。(您需要使用该单词,run因为testw它不是 npm 自动识别的特殊关键字之一。)

If everything went well, you can now type npm test in the command line from the ch2 folder, and Jest will run the tests once. If you type npm run testw, Jest will run and wait for changes in an endless loop, until you kill the process with Ctrl-C. (You need to use the word run because testw is not one of the special keywords that npm recognizes automatically.)

如果运行测试,您可以看到它通过了,因为该函数按预期工作。

If you run the test, you can see that it passes, since the function works as expected.

2.5.2 测试测试

2.5.2 Testing the test

让我们在生产代码中添加一个错误,看看测试是否会在应该失败的时候失败。

Let’s put a bug in the production code and see if the test fails when it should.

清单 2.4 添加错误

Listing 2.4 Adding a bug

const verifyPassword = (输入, 规则) => {
  常量错误=[];
  规则.forEach(规则=> {
    const 结果 = 规则(输入);
    if (!result.passed) {
      // error.push(`错误 ${result.reason}`);    
    }
  });
  返回错误;
};
const verifyPassword = (input, rules) => {
  const errors = [];
  rules.forEach(rule => {
    const result = rule(input);
    if (!result.passed) {
      // errors.push(`error ${result.reason}`);    
    }
  });
  return errors;
};

我们不小心注释掉了这一行。

We've accidentally commented out this line.

您现在应该看到您的测试失败并显示一条不错的消息。让我们取消注释该行并再次查看测试是否通过。如果您不进行测试驱动开发并且在代码之后编写测试,那么这是获得对测试的信心的好方法。

You should now see your test failing with a nice message. Let’s uncomment the line and see the test pass again. This is a great way to gain some confidence in your tests, if you’re not doing test-driven development and are writing the tests after the code.

2.5.3 使用命名

2.5.3 USE naming

我们的测试名声很不好。它没有解释我们在这里想要完成的任何事情。我喜欢在测试名称中放入三条信息,这样测试的读者只需查看测试名称就可以回答他们的大部分心理问题。这三个部分包括

Our test has a really bad name. It doesn’t explain anything about what we’re trying to accomplish here. I like to put three pieces of information in test names, so that the reader of the test will be able to answer most of their mental questions just by looking at the test name. These three parts include

  • 被测试的工作单元(verifyPassword在本例中为函数)

  • The unit of work under test (the verifyPassword function, in this case)

  • 场景或单元输入(失败的规则)

  • The scenario or inputs to the unit (the failed rule)

  • 预期的行为或退出点(返回带有原因的错误)

  • The expected behavior or exit point (returns an error with a reason)

在审阅过程中,该书的审阅者泰勒·莱姆克(Tyler Lemke)为此想出了一个很好的缩写词:USE:被测单元、场景、期望。我喜欢它,而且很容易记住。谢谢泰勒!

During the review process, Tyler Lemke, a reviewer of the book, came up with a nice acronym for this, USE: unit under test, scenario, expectation. I like it, and it’s easy to remember. Thanks Tyler!

以下列表显示了我们使用 USE 名称对测试进行的下一个修订版。

The following listing shows our next revision of the test with a USE name.

清单 2.5 使用 USE 命名测试

Listing 2.5 Naming a test with USE

test(' verifyPassword,给定失败的规则,返回错误', () => {
  const fakeRule = input => ({ pass: false, Reason: '假原因' });
 
  const error = verifyPassword('任何值', [fakeRule]);
  Expect(errors[0]) .toContain( '假原因' ) ;
});
test('verifyPassword, given a failing rule, returns errors', () => {
  const fakeRule = input => ({ passed: false, reason: 'fake reason' });
 
  const errors = verifyPassword('any value', [fakeRule]);
  expect(errors[0]).toContain('fake reason');
});

这个好一点了。当测试失败时,特别是在构建过程中,您看不到注释或完整的测试代码。您通常只会看到测试的名称。名称应该非常清晰,您甚至不需要查看测试代码就可以了解生产代码问题可能出在哪里。

This is a bit better. When a test fails, especially during a build process, you don’t see comments or the full test code. You usually only see the name of the test. The name should be so clear that you might not even have to look at the test code to understand where the production code problem might be.

2.5.4 字符串比较和可维护性

2.5.4 String comparisons and maintainability

我们还在以下行中做了另一个小更改:

We also made another small change in the following line:

Expect(errors[0]) .toContain ('假原因');
expect(errors[0]).toContain('fake reason');

我们不是像测试中常见的那样检查一个字符串是否与另一个字符串相等,而是检查输出中是否包含一个字符串。这使得我们的测试对于未来输出的变化不那么脆弱。我们可以使用.toContainor.toMatch(/fake reason/)来实现这一点,它使用正则表达式来匹配字符串的一部分。

Instead of checking that one string is equal to another, as is very common in tests, we are checking that a string is contained in the output. This makes our test less brittle for future changes to the output. We can use .toContain or .toMatch(/fake reason/), which uses a regular expression to match a part of the string, to achieve this.

字符串是用户界面的一种形式。它们对人类来说是可见的,并且可能会发生变化——尤其是弦的边缘。我们可能会在字符串中添加空格、制表符、星号或其他修饰符。我们关心字符串中包含的核心信息是否存在。我们不想每次有人在字符串末尾添加新行时都更改我们的测试。这是我们希望在测试中鼓励的思维的一部分:随着时间的推移,测试的可维护性和对测试脆弱性的抵抗力是重中之重。

Strings are a form of user interface. They are visible to humans, and they might change—especially the edges of strings. We might add whitespace, tabs, asterisks, or other embellishments to a string. We care that the core of the information contained in the string exists. We don’t want to change our test every time someone adds a new line to the end of a string. This is part of the thinking we want to encourage in our tests: test maintainability over time, and resistance to test brittleness, are of high priority.

理想情况下,我们希望测试仅在生产代码中确实出现错误时才失败。我们希望将误报数量减少到最低限度。使用toContain()ortoMatch()是实现该目标的好方法。

We’d ideally like the test to fail only when something is actually wrong in the production code. We’d like to reduce the number of false positives to a minimum. Using toContain() or toMatch() is a great way to move toward that goal.

我将在整本书中讨论更多提高测试可维护性的方法,特别是在本书的第二部分。

I’ll talk about more ways to improve test maintainability throughout the book, and especially in part 2 of the book.

2.5.5 使用describe()

2.5.5 Using describe()

我们可以使用 Jest 的describe()函数围绕我们的测试创建更多结构,并开始将三个 USE 信息相互分离。这一步和之后的步骤完全由您决定——您可以决定如何设计测试及其可读性结构。我向您展示这些步骤是因为许多人要么没有describe()以有效的方式使用,要么完全忽略它。它可能非常有用。

We can use Jest’s describe() function to create a bit more structure around our test and to start separating the three USE pieces of information from each other. This step and the ones after it are completely up you—you can decide how you want to style your test and its readability structure. I’m showing you these steps because many people either don’t use describe() in an effective way, or they ignore it altogether. It can be quite useful.

这些describe()函数用上下文包装我们的测试:既为读者提供逻辑上下文,又为测试本身提供功能上下文。下一个清单显示了我们如何开始使用它们。

The describe() functions wrap our tests with context: both logical context for the reader, and functional context for the test itself. The next listing shows how we can start using them.

清单 2.6 添加一个describe()

Listing 2.6 Adding a describe() block

描述('验证密码',()=> {
  test('给出失败的规则,返回错误', () => {
    常量 fakeRule = 输入 =>
      ({ pass: false, Reason: '假原因' });
 
    const error = verifyPassword('任何值', [fakeRule]);
 
    Expect(errors[0]).toContain('假原因');
  });
});
describe('verifyPassword', () => {
  test('given a failing rule, returns errors', () => {
    const fakeRule = input =>
      ({ passed: false, reason: 'fake reason' });
 
    const errors = verifyPassword('any value', [fakeRule]);
 
    expect(errors[0]).toContain('fake reason');
  });
});

我在这里做了四处更改:

I’ve made four changes here:

  • 我添加了一个describe()块来描述被测试的工作单元。对我来说,这看起来更清楚。感觉我现在可以在该块下添加更多嵌套测试。该describe()块还可以帮助命令行报告器创建更好的报告。

  • I’ve added a describe() block that describes the unit of work under test. To me this looks clearer. It also feels like I can now add more nested tests under that block. This describe() block also helps the command-line reporter create nicer reports.

  • 我已将 嵌套test在新块下,并从测试中删除了工作单元的名称。

  • I’ve nested the test under the new block and removed the name of the unit of work from the test.

  • 我已将其添加input到假规则的reason字符串中。

  • I’ve added the input into the fake rule’s reason string.

  • 我在排列、执行和断言部分之间添加了一个空行,以使测试更具可读性,特别是对于团队的新成员来说。

  • I’ve added an empty line between the arrange, act, and assert parts to make the test more readable, especially to someone new to the team.

2.5.6 暗示上下文的结构

2.5.6 Structure implying context

好处describe()是它可以嵌套在自身之下。因此,我们可以使用它来创建另一个级别来解释场景,并在其下嵌套我们的测试。

The nice thing about describe() is that it can be nested under itself. So we can use it to create another level that explains the scenario, and under that we’ll nest our test.

清单 2.7describe用于额外上下文的嵌套

Listing 2.7 Nested describes for extra context

描述('verifyPassword', () => {
  描述('规则失败', () => {
    测试('返回错误', () => {
      const fakeRule = 输入 => ({ 传递: false,
                                   原因:'假原因'});
 
      const error = verifyPassword('任何值', [fakeRule]);
 
      Expect(errors[0]).toContain('假原因');
    });
  });
});
describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    test('returns errors', () => {
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason' });
 
      const errors = verifyPassword('any value', [fakeRule]);
 
      expect(errors[0]).toContain('fake reason');
    });
  });
});

有些人会讨厌它,但我认为它有一定的优雅。这种嵌套允许我们将三部分关键信息分离到各自的级别。describe()事实上,如果我们愿意的话,我们还可以在相关的测试之外提取错误规则。

Some people will hate it, but I think there’s a certain elegance to it. This nesting allows us to separate the three pieces of critical information to their own level. In fact, we can also extract the false rule outside of the test right under the relevant describe(), if we wish to.

清单 2.8describe带有提取输入的嵌套

Listing 2.8 Nested describes with an extracted input

描述('验证密码',()=> {
  描述('有一个失败的规则', () => {
    const fakeRule = input => ({ pass: false, 
                                 Reason: '假原因' });
 
    test('返回错误', () => {
      const error = verifyPassword('任何值', [fakeRule]);
 
      Expect(errors[0]).toContain('假原因');
    });
  });
});
describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    const fakeRule = input => ({ passed: false,
                                 reason: 'fake reason' });
 
    test('returns errors', () => {
      const errors = verifyPassword('any value', [fakeRule]);
 
      expect(errors[0]).toContain('fake reason');
    });
  });
});

对于下一个示例,我将把这条规则移回到测试中(我喜欢事情紧密结合在一起——稍后会详细介绍)。

For the next example, I’ll move this rule back into the test (I like it when things are close together—more on that later).

这种嵌套结构还很好地表明,在特定场景下,您可能会有多个预期行为。您可以在一个场景下检查多个出口点,每个出口点作为一个单独的测试,从读者的角度来看它仍然有意义。

This nesting structure also implies very nicely that under a specific scenario you could have more than one expected behavior. You could check multiple exit points under a scenario, with each one as a separate test, and it will still make sense from the reader’s point of view.

2.5.7 it()函数

2.5.7 The it() function

到目前为止,我一直在构建的拼图中缺少一块。Jest 还公开了一个it()函数。出于所有意图和目的,该函数是该函数的别名test(),但它在语法方面更适合迄今为止概述的描述驱动方法。

There’s one missing piece to the puzzle I’ve been building so far. Jest also exposes an it() function. This function is, for all intents and purposes, an alias to the test() function, but it fits in more nicely in terms of syntax with the describe-driven approach outlined so far.

test()以下清单显示了当我替换为时测试的样子it()

The following listing shows what the test looks like when I replace test() with it().

清单 2.9 替换test()it()

Listing 2.9 Replacing test() with it()

描述('验证密码',()=> {
  描述('有一个失败的规则', () => {
    it( '返回错误', () => {
      const fakeRule = 输入 => ({ 传递: false,
                                   原因:'假原因'});
 
      const error = verifyPassword('任何值', [fakeRule]);
 
      Expect(errors[0]).toContain('假原因');
    });
  });
});
describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    it('returns errors', () => {
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason' });
 
      const errors = verifyPassword('any value', [fakeRule]);
 
      expect(errors[0]).toContain('fake reason');
    });
  });
});

在这个测试中,很容易理解it所指的是什么。这是先前块的自然扩展describe()。同样,是否要使用这种样式取决于您。我正在展示我喜欢的思考方式的一种变体。

In this test, it’s very easy to understand what it refers to. This is a natural extension of the previous describe() blocks. Again, it’s up to you whether you want to use this style. I’m showing one variation of how I like to think about it.

2.5.8 两种 Jest 风格

2.5.8 Two Jest flavors

正如您所看到的,Jest 支持两种主要的测试编​​写方式:简洁的test语法和更describe驱动(即分层)的语法。

As you’ve seen, Jest supports two main ways to write tests: a terse test syntax, and a more describe-driven (i.e., hierarchical) syntax.

驱动的 Jest语法describe很大程度上归功于 Jasmine,它是最古老的 JavaScript 测试框架之一。这种风格本身可以追溯到Ruby-land和著名的RSpec Ruby测试框架。这种嵌套风格通常称为BDD风格,指的是行为驱动开发

The describe-driven Jest syntax can be largely attributed to Jasmine, one of the oldest JavaScript test frameworks. The style itself can be traced back to Ruby-land and the well-known RSpec Ruby test framework. This nested style is usually called BDD style, referring to behavior-driven development.

你可以根据自己的喜好混合搭配这些风格(我就是这么做的)。当您可以轻松理解测试目标及其所有上下文时,您可以使用该test语法,而不会遇到太多麻烦。describe当您希望在同一场景下从同一入口点获得多个结果时,该语法会有所帮助。我在这里展示它们是因为我有时使用简洁的test风格,有时使用 -describe驱动的风格,具体取决于复杂性和表现力要求。

You can mix and match these styles as you like (I do). You can use the test syntax when it’s easy to understand your test target and all of its context, without going to too much trouble. The describe syntax can help when you’re expecting multiple results from the same entry point under the same scenario. I’m showing them both here because I sometimes use the terse test flavor and sometimes use the describe-driven flavor, depending on the complexity and expressiveness requirements.

BDD 的黑暗现状

BDD’s dark present

BDD 有一个非常有趣的背景,可能值得一谈。BDD 与 TDD 无关。Dan North 是与该术语最相关的发明者,他将 BDD 称为使用故事和示例来描述应用程序应如何运行。主要是针对与非技术利益相关者(产品所有者、客户等)合作。RSpec(受 RBehave 启发)将故事驱动的方法带给大众,在此过程中,出现了许多其他框架,包括著名的 Cucumber 。

BDD has quite an interesting background that might be worth talking about. BDD isn’t related to TDD. Dan North, the person most associated with inventing the term, refers to BDD as using stories and examples to describe how an application should behave. Mainly this is targeted at working with non-technical stakeholders—product owners, customers, etc. RSpec (inspired by RBehave) brought the story-driven approach to the masses, and in the process, many other frameworks came along, including the famous Cucumber.

这个故事还有一个阴暗面:许多框架都是由开发人员单独开发和使用的,没有与非技术利益相关者合作,这与 BDD 的主要思想完全相反。

There is also a dark side to this story: many frameworks have been developed and used solely by developers without working with non-technical stakeholders, in complete opposition to the main ideas of BDD.

今天,对我来说,BDD 框架这个术语主要意味着“带有一些语法糖的测试框架”,因为它们几乎从未用于在利益相关者之间创建真正的对话,并且几乎总是用作另一个闪亮的或规定的工具来执行基于开发人员的自动化测试。我什至见过强大的黄瓜陷入这种模式。

Today, to me, the term BDD frameworks mainly means “test frameworks with some syntactic sugar,” since they are almost never used to create real conversations between stakeholders and are almost always used as just another shiny or prescribed tool for performing developer-based automated tests. I’ve even seen the mighty Cucumber fall into this pattern.

2.5.9 重构生产代码

2.5.9 Refactoring the production code

既然有很多方法为了用 JavaScript 构建同样的东西,我想展示我们的设计的一些变化以及如果我们改变它会发生什么。假设我们想让密码验证器成为一个具有状态的对象。

Since there are many ways to build the same thing in JavaScript, I thought I’d show a couple of variations on our design and what happens if we change it. Suppose we’d like to make the password verifier an object with state.

将设计更改为有状态设计的原因之一可能是我打算让应用程序的不同部分使用此对象。一个部分将配置并向其添加规则,另一部分将使用它进行验证。另一个原因是我们需要知道如何处理有状态设计,并了解它将我们的测试拉向哪个方向,以及我们可以采取什么措施。

One reason to change the design into a stateful one might be that I intend for different parts of the application to use this object. One part will configure and add rules to it, and a different part will use it to do the verification. Another reason is that we need to know how to handle a stateful design and look at which directions it pulls our tests in, and what we can do about that.

我们先看一下生产代码。

Let’s look at the production code first.

清单 2.10 将函数重构为有状态类

Listing 2.10 Refactoring a function to a stateful class

类PasswordVerifier1 {
  构造函数() { 
    this.rules = []; 
  }
 
  addRule (规则) { 
    this.rules.push(rule); 
  }
 
  验证(输入){
    常量错误=[];
    这。规则.forEach(规则=> {
      const 结果 = 规则(输入);
      if (结果.passed === false) {
        错误.push(结果.原因);
      }
    });
    返回错误;
  }
}
class PasswordVerifier1 {
  constructor () {
    this.rules = [];
  }
 
  addRule (rule) {
    this.rules.push(rule);
  }
 
  verify (input) {
    const errors = [];
    this.rules.forEach(rule => {
      const result = rule(input);
      if (result.passed === false) {
        errors.push(result.reason);
      }
    });
    return errors;
  }
}

我已经强调了清单 2.9 中的主要变化。这里并没有什么特别的地方,不过如果您有面向对象的背景,这可能会让您感觉更舒服。值得注意的是,这只是设计此功能的一种方法。我使用基于类的方法,以便可以展示此设计如何影响测试。

I’ve highlighted the main changes from listing 2.9. There’s nothing really special going on here, though this may feel more comfortable if you’re coming from an object-oriented background. It’s important to note that this is just one way to design this functionality. I’m using the class-based approach so that I can show how this design affects the test.

在这个新设计中,当前场景的入口点和出口点在哪里?想一想。工作单元的范围扩大了。要测试具有失败规则的场景,我们必须调用两个影响被测单元状态的函数:addRuleverify

In this new design, where are the entry and exit points for the current scenario? Think about it for a second. The scope of the unit of work has increased. To test a scenario with a failing rule, we would have to invoke two functions that affect the state of the unit under test: addRule and verify.

现在让我们看看测试可能是什么样子(更改像往常一样突出显示)。

Now let’s see what the test might look like (changes are highlighted as usual).

清单 2.11 测试有状态工作单元

Listing 2.11 Testing the stateful unit of work

描述('密码验证器', () => {
  描述('有一个失败的规则', () => {
    it('有一条基于规则的错误消息。原因', () => {
      const 验证器 = new PasswordVerifier1();
      const fakeRule = 输入 => ({ 传递: false,
                                   原因:'假原因'});
 
      verifier.addRule(fakeRule);
      const error = verifier.verify('任意值');
 
      Expect(errors[0]).toContain('假原因');
    });
  });
});
describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
      const verifier = new PasswordVerifier1();
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason'});
 
      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');
 
      expect(errors[0]).toContain('fake reason');
    });
  });
});

到目前为止,一切都很好; 这里没有发生什么奇怪的事情。请注意,工作单元的表面有所增加。它现在涵盖两个必须协同工作的相关功能(addRuleverify)。由于设计的有状态性质,会发生耦合我们需要使用两个函数来有效地进行测试,而不暴露对象的任何内部状态。

So far, so good; nothing fancy is happening here. Note that the surface of the unit of work has increased. It now spans two related functions that must work together (addRule and verify). There is a coupling that occurs due to the stateful nature of the design. We need to use two functions to test productively without exposing any internal state from the object.

测试本身看起来很无辜。但是,当我们想为同一场景编写多个测试时会发生什么?如果我们有多个退出点,或者如果我们想从同一退出点测试多个结果,就会发生这种情况。例如,假设我们想要验证是否只有一个错误。我们可以简单地在测试中添加一行,如下所示:

The test itself looks innocent enough. But what happens when we want to write several tests for the same scenario? That would happen if we have multiple exit points, or if we want to test multiple results from the same exit point. For example, let’s say we want to verify that we have only a single error. We could simply add a line to the test like this:

verifier.addRule(fakeRule);
const error = verifier.verify('任意值');
期望(errors.length).toBe(1);                 ❶expect 
(errors[0]).toContain('假原因');
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1);                 
expect(errors[0]).toContain('fake reason');

新的主张

A new assertion

如果新断言失败会发生什么?第二个断言永远不会执行,因为测试运行程序将收到错误并继续执行下一个测试用例。

What happens if the new assertion fails? The second assertion would never execute, because the test runner would receive an error and move on to the next test case.

我们仍然想知道第二个断言是否会通过,对吧?因此,也许我们应该开始注释掉第一个并重新运行测试。这不是运行测试的健康方式。在 Gerard Meszaros 的书xUnit Test Patterns中,这种通过注释事物来测试其他事物的人类行为被称为断言轮盘赌。它可能会在测试运行中产生很多混乱和误报(认为某件事失败或通过,但事实并非如此)。

We’d still want to know if the second assertion would have passed, right? So maybe we’d start commenting out the first one and rerunning the test. That’s not a healthy way to run your tests. In Gerard Meszaros’ book xUnit Test Patterns, this human behavior of commenting things out to test other things is called assertion roulette. It can create lots of confusion and false positives in your test runs (thinking that something is failing or passing when it isn’t).

我宁愿将这个额外的检查分离到它自己的测试用例中,并命名一个好名字,如下所示。

I’d rather separate this extra check into its own test case with a good name, as follows.

清单 2.12 检查来自同一出口点的额外最终结果

Listing 2.12 Checking an extra end result from the same exit point

描述('密码验证器', () => {
  描述('有一个失败的规则', () => {
    it('有一条基于规则的错误消息。原因', () => {
      const 验证器 = new PasswordVerifier1();
      const fakeRule = 输入 => ({ 传递: false,
                                   原因:'假原因'});
 
      verifier.addRule(fakeRule);
      const error = verifier.verify('任意值');
 
      Expect(errors[0]).toContain('假原因');
    });
    it('只有一个错误', () => { 
      const verifier = new PasswordVerifier1(); 
      const fakeRule = input => ({ Passed: false, 
                                   Reason: '假原因'});
 
      verifier.addRule(fakeRule); 
      const error = verifier.verify('任意值');
 
      期望(errors.length).toBe(1); 
    });
  });
});
describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
      const verifier = new PasswordVerifier1();
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason'});
 
      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');
 
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      const verifier = new PasswordVerifier1();
      const fakeRule = input => ({ passed: false,
                                   reason: 'fake reason'});
 
      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');
 
      expect(errors.length).toBe(1);
    });
  });
});

情况开始看起来很糟糕。是的,我们已经解决了断言轮盘赌问题。每个测试用例都it()可以单独失败,并且不会干扰其他测试用例的结果。但这花费了什么?一切。看看我们现在所有的重复。此时,那些有一些单元测试背景的人会开始对着书大喊:“使用setup/beforeEach方法!”

This is starting to look bad. Yes, we have solved the assertion roulette issue. Each it() can fail separately and not interfere with the results from the other test case. But what did it cost? Everything. Look at all the duplication we have now. At this point, those of you with some unit testing background will start shouting at the book: “Use a setup/beforeEach method!”

美好的!

Fine!

2.6 尝试 beforeEach() 路线

2.6 Trying the beforeEach() route

我还没介绍beforeEach()呢 这个函数和它的兄弟函数,afterEach(),用于设置和拆除测试用例所需的特定状态。还有beforeAll()afterAll(),我尽量避免在单元测试场景中使用它。我们将在本书后面详细讨论这对兄弟姐妹。

I haven’t introduced beforeEach() yet. This function and its sibling, afterEach(), are used to set up and tear down a specific state required by the test cases. There’s also beforeAll() and afterAll(), which I try to avoid using at all costs for unit testing scenarios. We’ll talk more about the siblings later in the book.

beforeEach()可以帮助我们消除测试中的重复,因为它在describe我们嵌套它的块中的每个测试之前运行一次。我们还可以多次嵌套它,如下面的清单所示。

beforeEach() can help us remove duplication in our tests because it runs once before each test in the describe block in which we nest it. We can also nest it multiple times, as the following listing demonstrates.

beforeEach()清单 2.13在两个级别上使用

Listing 2.13 Using beforeEach() on two levels

描述('PasswordVerifier', () => {
   let verifier; 
  beforeEach(() => verifier = new PasswordVerifier1());    
  描述('有一个失败的规则', () => {
    让 fakeRule,错误;
    beforeEach(() => {                                     
      fakeRule = input => ({passed: false, Reason: '假原因'}); 
      verifier.addRule(fakeRule); 
    });
    it('有一条基于规则的错误消息。原因', () => {
      const error = verifier.verify('任意值');
 
      Expect(errors[0]).toContain('假原因');
    });
    it('只有一个错误', () => {
      const error = verifier.verify('任意值');
 
      期望(errors.length).toBe(1);
    });
  });
});
describe('PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());   
  describe('with a failing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {                                    
      fakeRule = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRule);
    });
    it('has an error message based on the rule.reason', () => {
      const errors = verifier.verify('any value');
 
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      const errors = verifier.verify('any value');
 
      expect(errors.length).toBe(1);
    });
  });
});

设置将在每次测试中使用的新验证器

Setting up a new verifier that will be used in each test

设置将在该describe()方法中使用的假规则

Setting up a fake rule that will be used within this describe() method

查看所有提取的代码。

Look at all that extracted code.

在第一个中beforeEach(),我们正在设置一个PasswordVerifier1将为每个测试用例创建的新测试用例。之后beforeEach(),我们将设置一个虚假规则,并将其添加到特定场景下每个测试用例的新验证程序中。如果我们有其他场景,第 6 行中的第二个场景beforeEach()将不会运行,但第一个场景会运行。

In the first beforeEach(), we’re setting up a new PasswordVerifier1 that will be created for each test case. In the beforeEach() after that, we’re setting up a fake rule and adding it to the new verifier for every test case under that specific scenario. If we had other scenarios, the second beforeEach() in line 6 wouldn’t run for them, but the first one would.

现在测试看起来更短,这正是您在测试中想要的,以使其更具可读性和可维护性。我们从每个测试中删除了创建行并重用了相同的高级变量verifier

The tests seem shorter now, which ideally is what you want in a test, to make it more readable and maintainable. We removed the creation line from each test and reused the same higher-level variable verifier.

有一些注意事项:

There are a couple of caveats:

  • 我们忘记errorsbeforeEach()第 6 行重置数组。这可能会在稍后困扰我们。

  • We forgot to reset the errors array in beforeEach() on line 6. That could bite us later on.

  • Jest 默认情况下并行运行单元测试。这意味着将验证器移至第 2 行可能会导致并行测试出现问题,其中验证器可能会被并行运行中的不同测试覆盖,这会破坏正在运行的测试的状态。Jest 与我所知道的大多数其他语言中的单元测试框架有很大不同,它强调在单个线程中运行测试,而不是并行(至少默认情况下),以避免此类问题。对于 Jest,我们必须记住并行测试是现实的,因此具有共享上层状态的有状态测试(就像我们在第 2 行中所做的那样)可能会出现问题,并导致不稳定的测试因未知原因而失败。

  • Jest runs unit tests in parallel by default. This means that moving the verifier to line 2 may cause an issue with parallel tests, where the verifier could be overwritten by a different test on a parallel run, which would screw up the state of our running test. Jest is quite different from unit test frameworks in most other languages I know, which make a point of running tests in a single thread, not in parallel (at least by default), to avoid such issues. With Jest, we have to remember that parallel tests are a reality, so stateful tests with a shared upper state, like we have at line 2, can potentially be problematic and cause flaky tests that fail for unknown reasons.

我们将很快纠正这两个问题。

We’ll correct both of these issues soon.

2.6.1 beforeEach() 和滚动疲劳

2.6.1 beforeEach() and scroll fatigue

我们在重构的过程中丢失了一些东西beforeEach()

We lost a couple of things in the process of refactoring to beforeEach():

  • 如果我试图只读取这些it()部分,我将无法判断它们verifier是在哪里创建和声明的。我必须向上滚动才能理解。

  • If I’m trying to read only the it() parts, I can’t tell where the verifier is created and declared. I’d have to scroll up to understand.

  • 了解添加了什么规则也是如此。我必须查看上面的一层才能it()查看添加了哪些规则,或者查找describe()块描述。

  • The same goes for understanding what rule was added. I’d have to look one level above the it() to see what rule was added, or look up the describe() block description.

现在看来,这似乎并没有那么糟糕。但稍后我们会看到,随着场景列表大小的增加,这种结构开始变得有点复杂。较大的文件会带来我所说的滚动疲劳,要求测试读者上下滚动测试文件以了解测试的上下文和状态。这使得维护和阅读测试成为一件苦差事,而不是简单的阅读行为。

Right now, this doesn’t seem so bad. But we’ll see later that this structure starts to get a bit hairy as the scenario list increases in size. Larger files can bring about what I like to call scroll fatigue, requiring the test reader to scroll up and down the test file to understand the context and state of the tests. This makes maintaining and reading the tests a chore instead of a simple act of reading.

这种嵌套对于报告来说非常有用,但对于必须不断查找某些东西来自哪里的人类来说却很糟糕。如果您曾经尝试在浏览器的检查器窗口中调试 CSS 样式,您就会知道这种感觉。您会看到某个特定单元格由于某种原因以粗体显示。然后向上滚动以查看哪种样式使第三个节点下的<div>特殊嵌套单元格内的样式变为粗体。table

This nesting is great for reporting, but it sucks for humans who have to keep looking up where something came from. If you’ve ever tried to debug CSS styles in the browser’s inspector window, you’ll know the feeling. You’ll see that a specific cell is bold for some reason. Then you scroll up to see which style made that <div> inside nested cells in a special table under the third node bold.

让我们看看当我们在下面的列表中更进一步时会发生什么。由于我们正在删除重复项,因此我们还可以调用verifybeforeEach()从每个it(). 这基本上是将 AAA 模式中的编曲和表演部分放入函数中beforeEach()

Let’s see what happens when we take it one step further in the following listing. Since we’re in the process of removing duplication, we can also call verify in beforeEach() and remove an extra line from each it(). This is basically putting the arrange and act parts from the AAA pattern into the beforeEach() function.

清单 2.14 将排列和表演部分推入beforeEach()

Listing 2.14 Pushing the arrange and act parts into beforeEach()

描述('密码验证器', () => {
  让验证者;
  beforeEach(() => 验证器 = new PasswordVerifier1());
  描述('有一个失败的规则', () => {
    让 fakeRule,错误;
    之前(()=> {
      fakeRule = input => ({passed: false, Reason: '假原因'});
      verifier.addRule(fakeRule);
      错误= verifier.verify('任何值');
    });
    it('有一条基于规则的错误消息。原因', () => {
      Expect(errors[0]).toContain('假原因');
    });
    it('只有一个错误', () => {
      期望(errors.length).toBe(1);
    });
  });
});
describe('PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {
      fakeRule = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRule);
      errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      expect(errors.length).toBe(1);
    });
  });
});

代码重复已减少到最低限度,但现在errors如果我们想了解每个it().

The code duplication has been reduced to a minimum, but now we also need to look up where and how we got the errors array if we want to understand each it().

让我们加倍努力,添加一些更基本的场景,看看这种方法是否可以随着问题空间的增加而扩展。

Let’s double down and add a few more basic scenarios, and see if this approach is scalable as the problem space increases.

清单 2.15 添加额外的场景

Listing 2.15 Adding extra scenarios

描述('v6 密码验证器', () => {
  让验证者;
  beforeEach(() => 验证器 = new PasswordVerifier1());
  描述('有一个失败的规则', () => {
    让 fakeRule,错误;
    之前(()=> {
      fakeRule = input => ({passed: false, Reason: '假原因'});
      verifier.addRule(fakeRule);
      错误= verifier.verify('任何值');
    });
    it('有一条基于规则的错误消息。原因', () => {
      Expect(errors[0]).toContain('假原因');
    });
    it('只有一个错误', () => {
      期望(errors.length).toBe(1);
    });
  });
  描述('带有传递规则',()=> { 
    let fakeRule,errors; 
    beforeEach(()=> { 
      fakeRule = input =>({passed:true,reason:''}); 
      verifier.addRule(fakeRule) ; 
      error = verifier.verify('任何值'); 
    }); 
    it('没有错误', () => { 
      Expect(errors.length).toBe(0); 
    }); 
  }); 
  描述('具有失败和通过规则',()=> { 
    let fakeRulePass,fakeRuleFail,errors; 
    beforeEach(()=> { 
      fakeRulePass = input =>({passed:true,reason:'假成功'}) ; 
      fakeRuleFail = input => ({passed: false, Reason: '假原因'}); 
      verifier.addRule(fakeRulePass); 
      verifier.addRule(fakeRuleFail); 
      errors = verifier.verify('任意值'); 
    }); 
    it('有一个错误', () => { 
      Expect(errors.length).toBe(1); 
    }); 
    it('错误文本属于失败的规则', () => { 
      Expect(errors[0] ).toContain('假原因'); 
    }); 
  }); 
});
describe('v6 PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {
      fakeRule = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRule);
      errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      expect(errors.length).toBe(1);
    });
  });
  describe('with a passing rule', () => {
    let fakeRule, errors;
    beforeEach(() => {
      fakeRule = input => ({passed: true, reason: ''});
      verifier.addRule(fakeRule);
      errors = verifier.verify('any value');
    });
    it('has no errors', () => {
      expect(errors.length).toBe(0);
    });
  });
  describe('with a failing and a passing rule', () => {
    let fakeRulePass,fakeRuleFail, errors;
    beforeEach(() => {
      fakeRulePass = input => ({passed: true, reason: 'fake success'});
      fakeRuleFail = input => ({passed: false, reason: 'fake reason'});
      verifier.addRule(fakeRulePass);
      verifier.addRule(fakeRuleFail);
      errors = verifier.verify('any value');
    });
    it('has one error', () => {
      expect(errors.length).toBe(1);
    });
    it('error text belongs to failed rule', () => {
      expect(errors[0]).toContain('fake reason');
    });
  });
});

我们喜欢这个吗?我不。现在我们看到了一些额外的问题:

Do we like this? I don’t. Now we’re seeing a couple of extra problems:

  • 我已经开始看到这些beforeEach()部分有很多重复。

  • I can already start to see lots of repetition in the beforeEach() parts.

  • 滚动疲劳的可能性急剧增加,更多的选项会beforeEach()影响哪个it()状态。

  • The potential for scroll fatigue has increased dramatically, with more options of which beforeEach() affects which it() state.

在实际项目中,beforeEach()函数往往是测试文件的垃圾箱。人们将各种测试初始化​​的东西扔在那里:只有某些测试需要的东西,影响所有其他测试的东西,以及没有人再使用的东西。把东西放在最容易的地方是人的本性,尤其是如果你之前的其他人也这样做的话。

In real projects, beforeEach() functions tend to be the garbage bin of the test file. People throw all kinds of test-initialized stuff in there: things that only some tests need, things that affect all the other tests, and things that nobody uses anymore. It’s human nature to put things in the easiest place possible, especially if everyone else before you has done so as well.

我对这种方法并不着迷beforeEach()。让我们看看是否可以缓解其中一些问题,同时仍将重复保持在最低限度。

I’m not crazy about the beforeEach() approach. Let’s see if we can mitigate some of these issues while still keeping duplication to a minimum.

2.7 尝试工厂方法路线

2.7 Trying the factory method route

工厂方法是简单的辅助函数,可以帮助我们构建对象或特殊状态,并在多个地方重用相同的逻辑。也许我们可以通过使用清单 2.16 中的失败和通过规则的几个工厂方法来减少一些重复和笨重的代码。

Factory methods are simple helper functions that help us build objects or special states and reuse the same logic in multiple places. Perhaps we can reduce some of the duplication and clunky-feeling code by using a couple of factory methods for the failing and passing rules in listing 2.16.

清单 2.16 添加一些工厂方法

Listing 2.16 Adding a couple of factory methods to the mix

描述('密码验证器', () => {
  让验证者;
  beforeEach(() => 验证器 = new PasswordVerifier1());
  描述('有一个失败的规则', () => {
    让错误;
    之前(()=> {
      verifier.addRule( makeFailingRule('假原因') );
      错误= verifier.verify('任何值');
    });
    it('有一条基于规则的错误消息。原因', () => {
      Expect(errors[0]).toContain('假原因');
    });
    it('只有一个错误', () => {
      期望(errors.length).toBe(1);
    });
  });
  描述('具有传递规则',()=> {
    让错误;
    之前(()=> {
      verifier.addRule( makePassingRule() );
      错误= verifier.verify('任何值');
    });
    it('没有错误', () => {
      期望(errors.length).toBe(0);
    });
  });
  描述('失败和通过规则', () => {
    让错误;
    之前(()=> {
      verifier.addRule( makePassingRule() );
      verifier.addRule( makeFailingRule('假原因') );
      错误= verifier.verify('任何值');
    });
    it('有一个错误', () => {
      期望(errors.length).toBe(1);
    });
    it('错误文本属于失败的规则', () => {
      Expect(errors[0]).toContain('假原因');
    });
  });
。。。
  const makeFailingRule = (原因) => { 
    return (输入) => { 
      return { 通过: false, 原因: 原因 }; 
    }; 
  }; 
  const makePassingRule = () => (输入) => { 
    return { 通过: true, 原因: '' }; 
  }; 
})
describe('PasswordVerifier', () => {
  let verifier;
  beforeEach(() => verifier = new PasswordVerifier1());
  describe('with a failing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makeFailingRule('fake reason'));
      errors = verifier.verify('any value');
    });
    it('has an error message based on the rule.reason', () => {
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      expect(errors.length).toBe(1);
    });
  });
  describe('with a passing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makePassingRule());
      errors = verifier.verify('any value');
    });
    it('has no errors', () => {
      expect(errors.length).toBe(0);
    });
  });
  describe('with a failing and a passing rule', () => {
    let errors;
    beforeEach(() => {
      verifier.addRule(makePassingRule());
      verifier.addRule(makeFailingRule('fake reason'));
      errors = verifier.verify('any value');
    });
    it('has one error', () => {
      expect(errors.length).toBe(1);
    });
    it('error text belongs to failed rule', () => {
      expect(errors[0]).toContain('fake reason');
    });
  });
. . .
  const makeFailingRule = (reason) => {
    return (input) => {
      return { passed: false, reason: reason };
    };
  };
  const makePassingRule = () => (input) => {
    return { passed: true, reason: '' };
  };
}) 

和工厂方法让我们的功能更加清晰一些makeFailingRule()makePassingRule()beforeEach()

The makeFailingRule() and makePassingRule() factory methods have made our beforeEach() functions a little more clear.

2.7.1 用工厂方法完全替换 beforeEach()

2.7.1 Replacing beforeEach() completely with factory methods

如果我们根本不使用它beforeEach()来初始化各种东西怎么办?如果我们改用小工厂方法会怎样?让我们看看它是什么样子的。

What if we don’t use beforeEach() to initialize various things at all? What if we switched to using small factory methods instead? Let’s see what that looks like.

清单 2.17 替换beforeEach()为工厂方法

Listing 2.17 Replacing beforeEach() with factory methods

const makeVerifier = () => new PasswordVerifier1(); 
const passingRule = (输入) => ({已通过: true, 原因: ''});
 
const makeVerifierWithPassingRule = () => { 
  const verifier = makeVerifier(); 
  verifier.addRule(passingRule); 
  返回验证者;
};
 
const makeVerifierWithFailedRule = (原因) => { 
  const verifier = makeVerifier(); 
  const fakeRule = 输入 => ({已通过: false, 原因: 原因}); 
  verifier.addRule(fakeRule); 
  返回验证者;
};
 
描述('密码验证器', () => {
  描述('有一个失败的规则', () => {
    it('有一条基于规则的错误消息。原因', () => {
      const verifier = makeVerifierWithFailedRule('假原因');
      const error = verifier.verify('任何输入');
      Expect(errors[0]).toContain( '假原因' );
    });
    it('只有一个错误', () => {
      const verifier = makeVerifierWithFailedRule('假原因');
      const error = verifier.verify('任何输入');
      期望(errors.length).toBe(1);
    });
  });
  描述('具有传递规则',()=> {
    it('没有错误', () => {
      const 验证器 = makeVerifierWithPassingRule();
      const error = verifier.verify('任何输入');
      期望(errors.length).toBe(0);
    });
  });
  描述('失败和通过规则', () => {
    it('有一个错误', () => {
      const verifier = makeVerifierWithFailedRule('假原因'); 
      verifier.addRule(passingRule);
      const error = verifier.verify('任何输入');
      期望(errors.length).toBe(1);
    });
    it('错误文本属于失败的规则', () => {
      const verifier = makeVerifierWithFailedRule('假原因'); 
      verifier.addRule(passingRule);
      const error = verifier.verify('任何输入');
      Expect(errors[0]).toContain( '假原因' );
    });
  });
});
const makeVerifier = () => new PasswordVerifier1();
const passingRule = (input) => ({passed: true, reason: ''});
 
const makeVerifierWithPassingRule = () => {
  const verifier = makeVerifier();
  verifier.addRule(passingRule);
  return verifier;
};
 
const makeVerifierWithFailedRule = (reason) => {
  const verifier = makeVerifier();
  const fakeRule = input => ({passed: false, reason: reason});
  verifier.addRule(fakeRule);
  return verifier;
};
 
describe('PasswordVerifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
      const verifier = makeVerifierWithFailedRule('fake reason');
      const errors = verifier.verify('any input');
      expect(errors[0]).toContain('fake reason');
    });
    it('has exactly one error', () => {
      const verifier = makeVerifierWithFailedRule('fake reason');
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(1);
    });
  });
  describe('with a passing rule', () => {
    it('has no errors', () => {
      const verifier = makeVerifierWithPassingRule();
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(0);
    });
  });
  describe('with a failing and a passing rule', () => {
    it('has one error', () => {
      const verifier = makeVerifierWithFailedRule('fake reason');
      verifier.addRule(passingRule);
      const errors = verifier.verify('any input');
      expect(errors.length).toBe(1);
    });
    it('error text belongs to failed rule', () => {
      const verifier = makeVerifierWithFailedRule('fake reason');
      verifier.addRule(passingRule);
      const errors = verifier.verify('any input');
      expect(errors[0]).toContain('fake reason');
    });
  });
});

这里的长度与清单 2.16 中的长度大致相同,但我发现代码更具可读性,因此更容易维护。我们取消了这些beforeEach()功能,但并没有失去可维护性。我们消除的重复量可以忽略不计,但由于删除了嵌套beforeEach()块,可读性大大提高。

The length here is about the same as in listing 2.16, but I find the code to be more readable and thus more easily maintained. We’ve eliminated the beforeEach() functions, but we didn’t lose maintainability. The amount of repetition we’ve eliminated is negligible, but the readability has improved greatly due to the removal of the nested beforeEach() blocks.

此外,我们还降低了滚动疲劳的风险。作为测试的读者,我不必上下滚动文件来查找对象何时创建或声明。我可以从 中收集所有信息it()。我们不需要知道某些东西是如何创建的,但我们知道它何时创建以及使用哪些重要参数进行初始化。一切都得到明确解释。

Furthermore, we’ve reduced the risk of scroll fatigue. As a reader of the test, I don’t have to scroll up and down the file to find out when an object is created or declared. I can glean all the information from the it(). We don’t need to know how something is created, but we know when it is created and what important parameters it is initialized with. Everything is explicitly explained.

如果需要,我可以深入研究特定的工厂方法,并且我喜欢每个方法it()都封装自己的状态。嵌套describe()结构是了解我们所在位置的好方法,但状态都是从it()块内部触发的,而不是在块外部触发。

If the need arises, I can drill into specific factory methods, and I like that each it() is encapsulating its own state. The nested describe() structure is a good way to know where we are, but the state is all triggered from inside the it() blocks, not outside of them.

2.8 绕一圈到 test()

2.8 Going full circle to test()

清单 2.17 中的测试足够自我封装,这些describe()块仅充当理解的附加糖。如果我们不需要它们,就不再需要它们。如果我们愿意,我们可以编写如下所示的测试。

The tests in listing 2.17 are self-encapsulated enough that the describe() blocks act only as added sugar for understanding. They are no longer needed if we don’t want them. If we wanted to, we could write the tests as in the following listing.

清单 2.18 删除嵌套描述

Listing 2.18 Removing nested describes

test('通过验证器,规则失败,' + 
          '根据规则有错误消息。reason', () => {
  const verifier = makeVerifierWithFailedRule('假原因');
  const error = verifier.verify('任何输入');
  Expect(errors[0]).toContain('假原因');
});
test('通过验证器,但规则失败,只有一个错误', () => {
  const verifier = makeVerifierWithFailedRule('假原因');
  const error = verifier.verify('任何输入');
  期望(errors.length).toBe(1);
});
test('通过验证器,有通过规则,没有错误', () => {
  const 验证器 = makeVerifierWithPassingRule();
  const error = verifier.verify('任何输入');
  期望(errors.length).toBe(0);
});
test('通过验证器,有通过和失败规则,' +
          ' 有一个错误', () => {
  const verifier = makeVerifierWithFailedRule('假原因');
  verifier.addRule(passingRule);
  const error = verifier.verify('任何输入');
  期望(errors.length).toBe(1);
});
test('通过验证器,有通过和失败规则,' + 
          '错误文本属于失败规则', () => {
  const verifier = makeVerifierWithFailedRule('假原因');
  verifier.addRule(passingRule);
  const error = verifier.verify('任何输入');
  Expect(errors[0]).toContain('假原因');
});
test('pass verifier, with failed rule, ' +
          'has an error message based on the rule.reason', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  const errors = verifier.verify('any input');
  expect(errors[0]).toContain('fake reason');
});
test('pass verifier, with failed rule, has exactly one error', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(1);
});
test('pass verifier, with passing rule, has no errors', () => {
  const verifier = makeVerifierWithPassingRule();
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(0);
});
test('pass verifier, with passing  and failing rule,' +
          ' has one error', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  verifier.addRule(passingRule);
  const errors = verifier.verify('any input');
  expect(errors.length).toBe(1);
});
test('pass verifier, with passing  and failing rule,' +
          ' error text belongs to failed rule', () => {
  const verifier = makeVerifierWithFailedRule('fake reason');
  verifier.addRule(passingRule);
  const errors = verifier.verify('any input');
  expect(errors[0]).toContain('fake reason');
});

工厂方法为我们提供了所需的所有功能,同时又不会失去每个特定测试的清晰度。

The factory methods provide us with all the functionality we need, without losing clarity for each specific test.

我有点喜欢清单 2.18 的简洁性。这很容易理解。我们可能会在这里失去一些结构清晰度,因此在某些情况下我会使用 -lessdescribe方法,并且在某些地方嵌套describe会使内容更具可读性。您的项目的可维护性和可读性的最佳点可能位于这两点之间。

I kind of like the terseness of listing 2.18. It’s easy to understand. We might lose a bit of structure clarity here, so there are instances where I go with the describe-less approach, and there are places where nested describes make things more readable. The sweet spot of maintainability and readability for your project is probably somewhere between these two points.

2.9 重构参数化测试

2.9 Refactoring to parameterized tests

让我们离开类verifier来为验证器创建和测试新的自定义规则。清单 2.19 显示了一个针对大写字母的简单规则(我意识到具有这些要求的密码不再被认为是一个好主意,但出于演示目的,我同意它)。

Let’s move away from the verifier class to work on creating and testing a new custom rule for the verifier. Listing 2.19 shows a simple rule for an uppercase letter (I realize passwords with these requirements are no longer considered a great idea, but for demonstration purposes I’m okay with it).

清单 2.19 密码规则

Listing 2.19 Password rules

const oneUpperCaseRule = (输入) => {
  返回 {
    通过:(input.toLowerCase()!==输入),
    原因:“至少需要一个大写字母”
  };
};
const oneUpperCaseRule = (input) => {
  return {
    passed: (input.toLowerCase() !== input),
    reason: 'at least one upper case needed'
  };
};

我们可以编写一些测试,如下面的清单所示。

We could write a couple of tests as in the following listing.

清单 2.20 测试具有变体的规则

Listing 2.20 Testing a rule with variations

描述('一个大写规则', function () {
  test('如果没有大写,则失败', () => {
    const 结果 = oneUpperCaseRule('abc');
    期望(结果。通过)。toEqual(假);
  });
  test('给定一个大写字母,它通过', () => { 
    const result = oneUpperCaseRule('Abc'); 
    Expect(result.passed).toEqual(true); 
  }); 
  test('给出不同的大写字母,它通过', () => { 
    const result = oneUpperCaseRule('aBc'); 
    Expect(result.passed).toEqual(true); 
  }); 
});
describe('one uppercase rule', function () {
  test('given no uppercase, it fails', () => {
    const result = oneUpperCaseRule('abc');
    expect(result.passed).toEqual(false);
  });
  test('given one uppercase, it passes', () => {
    const result = oneUpperCaseRule('Abc');
    expect(result.passed).toEqual(true);
  });
  test('given a different uppercase, it passes', () => {
    const result = oneUpperCaseRule('aBc');
    expect(result.passed).toEqual(true);
  });
});

在清单 2.20 中,我强调了如果我们尝试相同的场景,但工作单元的输入略有变化,则可能会出现一些重复。在本例中,我们想要测试大写字母在哪里并不重要,只要它在那里就行。但是,如果我们想要更改大写逻辑,或者如果我们需要以某种方式纠正该用例的断言,这种重复将会伤害我们。

In listing 2.20 I highlighted some duplication we might have if we’re trying out the same scenario with small variations in the input to the unit of work. In this case, we want to test that it should not matter where the uppercase letter is, as long as it’s there. But this duplication will hurt us down the road if we ever want to change the uppercase logic, or if we need to correct the assertions in some way for that use case.

在 JavaScript 中创建参数化测试有几种方法,Jest 已经包含了一种内置方法:(test.each也别名为it.each)。下一个清单显示了我们如何使用此功能来删除测试中的重复项。

There are a few ways to create parameterized tests in JavaScript, and Jest already includes one that’s built in: test.each (also aliased to it.each). The next listing shows how we could use this feature to remove duplication in our tests.

清单 2.21 使用test.each

Listing 2.21 Using test.each

描述('一个大写规则', () => {
  test('如果没有大写,则失败', () => {
    const 结果 = oneUpperCaseRule('abc');
    期望(结果。通过)。toEqual(假);
  });
 
  test.each(['Abc',                                     
             'aBc'])                                    
    ('给定一个大写字母,它通过', (input) => {     
      const result = oneUpperCaseRule(input); 
      expect(result.passed).toEqual(真的); 
    }); 
});
describe('one uppercase rule', () => {
  test('given no uppercase, it fails', () => {
    const result = oneUpperCaseRule('abc');
    expect(result.passed).toEqual(false);
  });
 
  test.each(['Abc',                                    
             'aBc'])                                   
    ('given one uppercase, it passes', (input) => {    
      const result = oneUpperCaseRule(input);
      expect(result.passed).toEqual(true);
    });
});

传入映射到输入参数的值数组

Passing in an array of values that are mapped to the input parameter

使用数组中传递的每个输入参数

Using each input parameter passed in the array

在此示例中,测试将对数组中的每个值重复一次。一开始有点拗口,但是一旦你尝试过这种方法,它就会变得很容易使用。它也非常可读。

In this example, the test will repeat once for each value in the array. It’s a bit of a mouthful at first, but once you’ve tried this approach, it becomes easy to use. It’s also pretty readable.

如果我们想传递多个参数,我们可以将它们放在一个数组中,如下清单所示。

If we want to pass in multiple parameters, we can enclose them in an array, as in the following listing.

清单 2.22 重构test.each

Listing 2.22 Refactoring test.each

describe('一个大写规则', () => {
   test.each([ ['Abc', true],                     
              ['aBc', true], 
              ['abc', false]])                    
    ('给定 %s , %s ', (输入,预期) => {     
      const 结果 = oneUpperCaseRule(输入);
      期望(结果.通过).toEqual(预期);
    });
});
describe('one uppercase rule', () => {
  test.each([ ['Abc', true],                    
              ['aBc', true],
              ['abc', false]])                  
    ('given %s, %s ', (input, expected) => {    
      const result = oneUpperCaseRule(input);
      expect(result.passed).toEqual(expected);
    });
});

提供三个数组,每个数组有两个参数

Providing three arrays, each with two parameters

对缺失大写字符的新错误期望

A new false expectation for a missing uppercase character

Jest 自动将数组值映射到参数。

Jest maps the array values to arguments automatically.

不过,我们不必使用 Jest。JavaScript 具有足够的通用性,如果我们愿意的话,我们可以很容易地推出自己的参数化测试。

We don’t have to use Jest, though. JavaScript is versatile enough to allow us to roll out our own parameterized test quite easily if we want to.

清单 2.23 使用普通 JavaScriptfor

Listing 2.23 Using a vanilla JavaScript for

描述('一个大写规则,使用普通JS',()=> {
   const测试= { 
    'Abc':true,
    'aBc':true,
    'abc':false,
  };
 
  for (const [输入,预期] of Object.entries(tests)) { 
    test('给定${input}, ${expected} ', () => {
      const 结果 = oneUpperCaseRule(输入);
      期望(结果.通过).toEqual(期望);
    });
  }
});
describe('one uppercase rule, with vanilla JS for', () => {
  const tests = {
    'Abc': true,
    'aBc': true,
    'abc': false,
  };
 
  for (const [input, expected] of Object.entries(tests)) {
    test('given ${input}, ${expected}', () => {
      const result = oneUpperCaseRule(input);
      expect(result.passed).toEqual(expected);
    });
  }
});

这取决于你想使用哪一个(我喜欢保持简单并使用test.each)。关键是,Jest 只是一个工具。参数化测试的模式可以通过多种方式实现。这种模式给了我们很大的权力,但也给了我们很大的责任。滥用这种技术并创建更难理解的测试确实很容易。

It’s up to you which one you want to use (I like to keep it simple and use test.each). The point is, Jest is just a tool. The pattern of parameterized tests can be implemented in multiple ways. This pattern gives us a lot of power, but also a lot of responsibility. It’s really easy to abuse this technique and create tests that are harder to understand.

我通常尝试确保相同的场景(输入类型)适用于整个表。如果我在代码审查中审查这个测试,我会告诉编写它的人这个测试实际上是在测试两种不同的场景:一个没有大写,另一个有一个大写。我会将它们分成两个不同的测试。

I usually try to make sure that the same scenario (type of input) holds for the entire table. If I were reviewing this test in a code review, I would have told the person who wrote it that this test is actually testing two different scenarios: one with no uppercase, and a couple with one uppercase. I would split those out into two different tests.

在这个例子中,我想表明,摆脱许多测试并将它们全部放在一个大的测试中是非常容易的test.each——即使这会损害可读性——所以在使用这些特定的剪刀运行时要小心。

In this example, I wanted to show that it’s very easy to get rid of many tests and put them all in a big test.each—even when it hurts readability—so be careful when running with these specific scissors.

2.10 检查预期抛出的错误

2.10 Checking for expected thrown errors

有时我们需要设计一段代码,在正确的时间使用正确的数据抛出错误。如果我们将代码添加到verify函数中,如果没有配置规则,则抛出错误,会发生什么情况,如下一个清单所示?

Sometimes we need to design a piece of code that throws an error at the right time with the right data. What happens if we add code to the verify function that throws an error if there are no rules configured, as in the next listing?

清单 2.24 抛出错误

Listing 2.24 Throwing an error

verify (input) {
   if (this.rules.length === 0) { 
    throw new Error('没有配置规则'); 
  } 
  。。。
verify (input) {
  if (this.rules.length === 0) {
    throw new Error('There are no rules configured');
  }
  . . .

try我们可以使用/以老式的方式测试它,如果没有出现错误,catch则测试失败。

We could test it the old-fashioned way by using try/catch, and failing the test if we don’t get an error.

清单 2.25 测试异常try/catch

Listing 2.25 Testing exceptions with try/catch

test('验证,无规则,抛出异常', () => {
    const 验证器 = makeVerifier();
    尝试 {
        verifier.verify('任何输入');
        fail('错误是预期的,但没有抛出'); 
    } catch (e) { 
        Expect(e.message).toContain('没有配置规则');
    }
});
test('verify, with no rules, throws exception', () => {
    const verifier = makeVerifier();
    try {
        verifier.verify('any input');
        fail('error was expected but not thrown');
    } catch (e) {
        expect(e.message).toContain('no rules configured');
    }
});

使用失败()

Using fail()

从技术上讲,fail()它是 Jest 所基于的 Jasmine 原始分支的剩余 API。这是一种触发测试失败的方法,但它不在官方 Jest API 文档中,他们建议您改用它expect.assertions(1)。如果您从未达到预期,那么测试就会失败catch()。我发现只要fail()仍然有效,它就可以很好地满足我的目的,这就是为了演示为什么您不应该在单元测试中使用try/构造(如果您可以帮助的话)。catch

Technically, fail() is a leftover API from the original fork of Jasmine, which Jest is based on. It’s a way to trigger a test failure, but it’s not in the official Jest API docs, and they would recommend that you use expect.assertions(1) instead. This would fail the test if you never reached the catch() expectation. I find that as long as fail() still works, it does the job quite nicely for my purposes, which are to demonstrate why you shouldn’t use the try/catch construct in a unit test if you can help it.

try/catch模式是一种有效的方法,但打字非常冗长且烦人。Jest 与大多数其他框架一样,包含一个快捷方式来完成此类场景,使用expect().toThrowError().

This try/catch pattern is an effective method but very verbose and annoying to type. Jest, like most other frameworks, contains a shortcut to accomplish exactly this type of scenario, using expect().toThrowError().

清单 2.26 使用expect().toThrowError()

Listing 2.26 Using expect().toThrowError()

test('验证,无规则,抛出异常', () => {
    const 验证器 = makeVerifier();
    Expect(() => verifier.verify('任何输入') ) 
        .toThrowError(/没有配置规则/);       
});
test('verify, with no rules, throws exception', () => {
    const verifier = makeVerifier();
    expect(() => verifier.verify('any input'))
        .toThrowError(/no rules configured/);      
});

使用正则表达式而不是寻找确切的字符串

Using a regular expression instead of looking for the exact string

请注意,我使用正则表达式匹配来检查错误字符串是否包含特定字符串,并且不等于它,以便在字符串两侧发生变化时使测试更加面向未来。toThrowError有一些变体,您可以访问https://jestjs.io/了解所有相关信息。

Notice that I’m using a regular expression match to check that the error string contains a specific string, and is not equal to it, so as to make the test a bit more future-proof if the string changes on its sides. toThrowError has a few variations, and you can go to https://jestjs.io/ find out all about them.

笑话快照

Jest snapshots

Jest 有一个独特的功能,称为快照。它允许您渲染组件(在像 React 这样的框架中工作时),然后将当前渲染与该组件保存的快照进行匹配,包括其所有属性和 HTML。

Jest has a unique feature called Snapshots. It allows you to render a component (when working in a framework like React) and then match the current rendering to a saved snapshot of that component, including all of its properties and HTML.

我不会过多谈论这一点,但从我所看到的情况来看,此功能往往被严重滥用。您可以使用它来创建难以阅读的测试,如下所示:

I won’t be touching on this too much, but from what I’ve seen, this feature tends to be abused quite heavily. You can use it to create hard-to-read tests that look something like this:

它('渲染',()=>{
    期望(<MyComponent/>)。toMatchSnapshot() ;
});
it('renders',()=>{
    expect(<MyComponent/>).toMatchSnapshot(); 
});

这是迟钝的(很难推断正在测试的内容),并且它正在测试许多可能彼此不相关的东西。它还会由于您可能不关心的许多原因而中断,因此随着时间的推移,该测试的可维护性成本将会更高。这也是不编写可读和可维护的测试的一个很好的借口,因为你已经到了最后期限,但仍然必须证明你编写了测试。是的,它确实有一定的用途,但它很容易在其他类型的测试更相关的地方使用。

This is obtuse (hard to reason about what is being tested) and it’s testing many things that might not be related to one another. It will also break for many reasons that you might not care about, so the maintainability cost of that test will be higher over time. It’s also a great excuse not to write readable and maintainable tests, because you’re on a deadline but still have to show you write tests. Yes, it does serve a purpose, but it’s easy to use in places where other types of tests are more relevant.

如果您需要此方法的变体,请尝试使用toMatchInlineSnapshot()它。您可以在https://jestjs.io/docs/en/snapshot-testing找到更多信息。

If you need a variation of this, try using toMatchInlineSnapshot() instead. You can find more info at https://jestjs.io/docs/en/snapshot-testing.

2.11 设置测试类别

2.11 Setting test categories

如果您只想运行特定类别的测试,例如仅单元测试,或仅集成测试,或仅涉及应用程序特定部分的测试,Jest 目前无法定义测试用例类别。

If you’d like to run only a specific category of tests, such as only unit tests, or only integration tests, or only tests that touch a specific part of the application, Jest currently doesn’t have the ability to define test case categories.

不过,一切并没有失去。Jest 有一个特殊的--testPathPattern命令行标志,它允许我们定义 Jest 如何找到我们的测试。我们可以使用不同的路径来触发此命令,以执行我们想要运行的特定类型的测试(例如“‘集成’文件夹下的所有测试”)。您可以在https://jestjs.io/docs/en/cli获取完整详细信息。

All is not lost, though. Jest has a special --testPathPattern command-line flag, which allows us to define how Jest will find our tests. We can trigger this command with a different path for a specific type of test we’d like to run (such as “all tests under the ‘integration’ folder”). You can get the full details at https://jestjs.io/docs/en/cli.

另一种选择是为每个测试类别创建一个单独的 jest.config.js 文件,每个测试类别都有自己的testRegex配置和其他属性。

Another alternative is to create a separate jest.config.js file for each test category, each with its own testRegex configuration and other properties.

清单 2.27 创建单独的 jest.config.js 文件

Listing 2.27 Creating separate jest.config.js files

// jest.config.integration.js
var config = require('./jest.config')
config.testRegex = "集成\\.js$"
模块.exports = 配置
 
// jest.config.unit.js
var config = require('./jest.config')
config.testRegex = "unit\\.js$"
模块.exports = 配置
// jest.config.integration.js
var config = require('./jest.config')
config.testRegex = "integration\\.js$" 
module.exports = config
 
// jest.config.unit.js
var config = require('./jest.config')
config.testRegex = "unit\\.js$" 
module.exports = config

然后,对于每个类别,您可以创建一个单独的 npm 脚本,该脚本使用自定义配置文件调用 Jest 命令行:jest -c my.custom.jest.config.js.

Then, for each category, you can create a separate npm script that invokes the Jest command line with a custom config file: jest -c my.custom.jest.config.js.

清单 2.28 使用单独的 npm 脚本

Listing 2.28 Using separate npm scripts

//包.json
。。。
“脚本”:{
    "unit": "jest -c jest.config.unit.js",
    "integ": "jest -c jest.config.integration.js"
。。。
//Package.json
. . .
"scripts": {
    "unit": "jest -c jest.config.unit.js",
    "integ": "jest -c jest.config.integration.js"
. . .

在下一章中,我们将研究具有依赖性和可测试性问题的代码,并且我们将开始讨论伪造、间谍、模拟和桩的概念,以及如何使用它们针对此类代码编写测试。

In the next chapter, we’ll look at code that has dependencies and testability problems, and we’ll start discussing the idea of fakes, spies, mocks, and stubs, and how you can use them to write tests against such code.

概括

Summary

  • Jest 是一个流行的、开源的 JavaScript 应用程序测试框架。它同时充当编写测试时使用的测试库、用于在测试内部进行断言的断言库、测试运行器测试报告器

  • Jest is a popular, open source test framework for JavaScript applications. It simultaneously acts as a test library to use when writing tests, an assertion library for asserting inside the tests, a test runner, and a test reporter.

  • Arrange-Act-Assert ( AAA ) 是一种流行的结构化测试模式。它为所有测试提供了简单、统一的布局。一旦习惯了,您就可以轻松阅读和理解任何测试。

  • Arrange-Act-Assert (AAA) is a popular pattern for structuring tests. It provides a simple, uniform layout for all tests. Once you get used to it, you can easily read and understand any test.

  • 在 AAA 模式中,安排部分是您将被测系统及其依赖项置于所需状态的地方。在act部分中,您调用方法,传递准备好的依赖项,并捕获输出值(如果有)。在断言部分,您验证结果。

  • In the AAA pattern, the arrange section is where you bring the system under test and its dependencies to a desired state. In the act section, you call methods, pass the prepared dependencies, and capture the output value (if any). In the assert section, you verify the outcome.

  • 命名测试的一个好模式是在测试名称中包含被测工作单元、单元的场景或输入以及预期的行为或退出点。此模式的一个方便的助记符是 USE(单位、场景、期望)。

  • A good pattern for naming tests is to include in the name of the test the unit of work under test, the scenario or inputs to the unit, and the expected behavior or exit point. A handy mnemonic for this pattern is USE (unit, scenario, expectation).

  • Jest 提供了多个函数,有助于围绕多个相关测试创建更多结构。describe()是一个作用域函数,允许将多个测试(或测试组)分组在一起。一个很好的比喻describe()是包含测试或其他文件夹的文件夹。test()是表示单个测试的函数。it()是 的别名test(),但与 结合使用时可提供更好的可读性describe()

  • Jest provides several functions that help create more structure around multiple related tests. describe() is a scoping function that allows for grouping multiple tests (or groups of tests) together. A good metaphor for describe() is a folder containing tests or other folders. test() is a function denoting an individual test. it() is an alias for test(), but it provides better readability when used in combination with describe().

  • beforeEach()describe通过提取嵌套和函数常见的代码,有助于避免重复it

  • beforeEach() helps avoid duplication by extracting code that is common for the nested describe and it functions.

  • beforeEach()当您必须查看不同的地方才能了解测试的作用时,使用通常会导致滚动疲劳。

  • The use of beforeEach() often leads to scroll fatigue, when you have to look at various places to understand what a test does.

  • 带有简单测试(没有任何)的工厂方法beforeEach()可以提高可读性并有助于避免滚动疲劳。

  • Factory methods with plain tests (without any beforeEach()) improve readability and help avoid scroll fatigue.

  • 参数化测试有助于减少类似测试所需的代码量。缺点是,当您使测试变得更加通用时,它们的可读性就会降低。

  • Parameterized tests help reduce the amount of code needed for similar tests. The drawback is that the tests become less readable as you make them more generic.

  • 为了保持测试可读性和代码重用之间的平衡,仅对输入值进行参数化。为不同的输出值创建单独的测试

  • To maintain a balance between test readability and code reuse, only parameterize input values. Create separate tests for different output values.

  • Jest 不支持测试类别,但您可以使用该--testPathPattern标志运行测试组。testRegex也可以在配置文件中设置。

  • Jest doesn’t support test categories, but you can run groups of tests using the --testPathPattern flag. You can also set up testRegex in the configuration file.

第二部分 核心技术

Part 2 Core techniques

在第 1 部分介绍了基础知识后,我现在将介绍在现实世界中编写测试所需的核心测试和重构技术。

Having covered the basics in part 1, I’ll now introduce the core testing and refactoring techniques necessary for writing tests in the real world.

在第 3 章中,我们将研究桩以及它们如何帮助打破依赖关系。我们将介绍使代码更易于测试的重构技术,并且您将在此过程中了解接缝。

In chapter 3, we’ll examine stubs and how they help break dependencies. We’ll go over refactoring techniques that make code more testable, and you’ll learn about seams in the process.

在第 4 章中,我们将继续讨论模拟对象和交互测试,我们将了解模拟对象与桩的不同之处,并且我们将探讨伪造的概念。

In chapter 4, we’ll move on to mock objects and interaction testing, we’ll look at how mock objects differ from stubs, and we’ll explore the concept of fakes.

在第 5 章中,我们将介绍隔离框架(也称为模拟框架),以及它们如何解决手写模拟和桩中涉及的一些重复编码。第 6 章讨论异步代码,例如 Promise、计时器和事件,以及测试此类代码的各种方法。

In chapter 5, we’ll look at isolation frameworks, also known as mocking frameworks, and at how they solve some of the repetitive coding involved in handwritten mocks and stubs. Chapter 6 deals with asynchronous code, such as promises, timers, and events, and various approaches to testing such code.

 

 

3 使用桩打破依赖关系

3 Breaking dependencies with stubs

本章涵盖

This chapter covers

  • 依赖项类型 - 模拟、桩等
  • Types of dependencies—mocks, stubs, and more
  • 使用桩的原因
  • Reasons to use stubs
  • 功能注入技术
  • Functional injection techniques
  • 模块化注射技术
  • Modular injection techniques
  • 面向对象的注入技术
  • Object-oriented injection techniques

在上一章中,您使用 Jest 编写了第一个单元测试,我们更多地关注了测试本身的可维护性。场景非常简单,更重要的是,它是完全独立的。密码验证器不依赖外部模块,我们可以专注于其功能,而不必担心其他可能干扰它的事情。

In the previous chapter, you wrote your first unit test using Jest, and we looked more at the maintainability of the test itself. The scenario was pretty simple, and more importantly, it was completely self-contained. The Password Verifier had no reliance on outside modules, and we could focus on its functionality without worrying about other things that might interfere with it.

在该章中,我们在示例中使用了前两种类型的退出点:返回值退出点和基于状态的退出点。在本章中,我们将讨论最后一种类型——调用第三方。本章还将提出一个新的要求——让你的代码依赖于时间。我们将研究两种不同的处理方法——重构代码和在不重构的情况下对其进行猴子修补。

In that chapter, we used the first two types of exit points for our examples: return value exit points and state-based exit points. In this chapter, we’ll talk about the final type—calling a third party. This chapter will also present a new requirement—having your code rely on time. We’ll look at two different approaches to handling it—refactoring our code and monkey-patching it without refactoring.

对外部模块或函数的依赖可能并且将会使编写测试和使测试可重复变得更加困难,并且还可能导致测试不稳定。我们将代码中所依赖的外部事物称为依赖项。我将在本章后面更彻底地定义它们。这些依赖项可能包括时间、异步执行、使用文件系统或使用网络等内容,或者它们可能只是涉及使用非常难以配置或执行起来可能很耗时的内容。

The reliance on outside modules or functions can and will make it harder to write a test and to make the test repeatable, and it can also cause tests to be flaky. We call the external things that we rely on in our code dependencies. I’ll define them more thoroughly later in the chapter. These dependencies could include things like time, async execution, using the filesystem, or using the network, or they could simply involve using something that is very difficult to configure or that may be time consuming to execute.

3.1 依赖类型

3.1 Types of dependencies

根据我的经验,我们的工作单元可以使用两种主要类型的依赖关系:

In my experience, there are two main types of dependencies that our unit of work can use:

  • 传出依赖项- 代表我们工作单元的退出点的依赖项,例如调用记录器、将某些内容保存到数据库、发送电子邮件、通知 API 或 Webhook 发生了某些事情等。请注意,这些都是动词: “呼叫”、“发送”和“通知”。它们以一种“即发即忘”的方式从工作单元向外流动。每个都代表一个退出点,或者工作单元中特定逻辑流的结束。

  • Outgoing dependencies—Dependencies that represent an exit point of our unit of work, such as calling a logger, saving something to a database, sending an email, notifying an API or a webhook that something has happened, etc. Notice these are all verbs: “calling,” “sending,” and “notifying.” They are flowing outward from the unit of work in a sort of fire-and-forget scenario. Each represents an exit point, or the end of a specific logical flow in a unit of work.

  • 传入依赖项——不是退出点的依赖项。这些并不代表对工作单元的最终行为的要求。它们只是向工作单元提供特定于测试的专用数据或行为,例如数据库查询的结果、文件系统上文件的内容、网络响应等。请注意,这些都是被动数据作为先前操作的结果流入工作单元。

  • Incoming dependencies—Dependencies that are not exit points. These do not represent a requirement on the eventual behavior of the unit of work. They are merely there to provide test-specific specialized data or behavior to the unit of work, such as a database query’s result, the contents of a file on the filesystem, a network response, etc. Notice that these are all passive pieces of data that flow inward to the unit of work as the result of a previous operation.

图 3.1 并排显示了这些内容。

Figure 3.1 shows these side by side.

03-01



图 3.1 在左侧,退出点通过调用依赖项来实现。右侧,依赖项提供间接输入或行为,而不是退出点。

Figure 3.1 On the left, an exit point is implemented as invoking a dependency. On the right, the dependency provides indirect input or behavior and is not an exit point.

某些依赖项既可以是传入的,也可以是传出的——在某些测试中,它们将代表退出点,而在其他测试中,它们将用于模拟进入应用程序的数据。这些应该不是很常见,但它们确实存在,例如为传出消息返回成功/失败响应的外部 API。

Some dependencies can be both incoming and outgoing—in some tests they will represent exit points, and in other tests they will be used to simulate data coming into the application. These shouldn’t be very common, but they do exist, such as an external API that returns a success/fail response for an outgoing message.

考虑到这些类型的依赖关系,让我们看看《xUnit 测试模式》一书如何为测试中与其他事物类似的事物定义各种模式。表 3.1 列出了我对本书网站http://mng.bz/n1WK中的一些模式的看法。

With these types of dependencies in mind, let’s look at how the book xUnit Test Patterns defines the various patterns for things that look like other things in tests. Table 3.1 lists my thoughts about some patterns from the book’s website at http:// mng.bz/n1WK.

表 3.1 澄清有关桩和模拟的术语

Table 3.1 Clarifying terminology around stubs and mocks

类别

Category

图案

Pattern

目的

Purpose

用途

Uses

 

 

测试替身

Test double

桩和模拟的通用名称

Generic name for stubs and mocks

我也使用“假”这个词。

I also use the term fake.

Stub

虚拟对象

Dummy object

当唯一用途是作为 SUT 方法调用的不相关参数时,用于指定测试中使用的值

Used to specify the values to be used in tests when the only usage is as irrelevant arguments of SUT method calls

作为参数发送到入口点或作为 AAA 模式的排列部分。

Send as a parameter to the entry point or as the arrange part of the AAA pattern.

 

 

测试桩

Test stub

当逻辑依赖于其他软件组件的间接输入时,用于独立验证逻辑

Used to verify logic independently when it depends on indirect inputs from other software components

作为依赖项注入,并将其配置为将特定值或行为返回到 SUT 中。

Inject as a dependency, and configure it to return specific values or behavior into the SUT.

嘲笑

Mock

测试间谍

Test spy

当逻辑间接输出到其他软件组件时,用于独立验证逻辑

Used to verify logic independently when it has indirect outputs to other software components

覆盖真实对象上的单个函数,并验证假函数是否按预期调用。

Override a single function on a real object, and verify that the fake function was called as expected.

 

 

模拟对象

Mock object

当逻辑依赖于其他软件组件的间接输出时,用于独立验证逻辑

Used to verify logic independently when it depends on indirect outputs to other software components

将 fake 作为依赖项注入到 SUT 中,并验证该 fake 是否按预期调用。

Inject the fake as a dependency into the SUT, and verify that the fake was called as expected.

在本书的其余部分中,可以用另一种方式来思考这个问题:

Here’s another way to think about this for the rest of this book:

  • 打破了传入的依赖关系(间接输入)。桩是假模块、对象或函数,它们被测代码提供假行为或数据。我们并不反对他们。我们可以在一次测试中拥有许多桩。

  • Stubs break incoming dependencies (indirect inputs). Stubs are fake modules, objects, or functions that provide fake behavior or data into the code under test. We do not assert against them. We can have many stubs in a single test.

  • 模拟打破了传出依赖关系(间接输出或退出点)。模拟是我们断言在测试中调用的假模块、对象或函数。模拟代表单元测试中的退出点。因此,建议每个测试不要超过一个模拟。

  • Mocks break outgoing dependencies (indirect outputs or exit points). Mocks are fake modules, objects, or functions that we assert were called in our tests. A mock represents an exit point in a unit test. Because of this, it is recommended that you have no more than a single mock per test.

不幸的是,在许多商店中,您会听到“mock”这个词,它是桩和模拟的统称。像“我们将模拟这个”或“我们有一个模拟数据库”这样的短语确实会造成混乱。桩和模拟之间存在巨大差异(一个实际上应该在测试中只使用一次),我们应该使用正确的术语来确保清楚对方所指的内容。

Unfortunately, in many shops you’ll hear the word “mock” thrown around as a catch-all term for both stubs and mocks. Phrases like “we’ll mock this out” or “we have a mock database” can really create confusion. There is a huge difference between stubs and mocks (one should really only be used once in a test), and we should use the right terms to ensure it’s clear what the other person is referring to.

如有疑问,请使用术语“测试替身”或“假的”。通常,单个假依赖项可以在一个测试中用作桩,并且可以在另一测试中用作模拟。稍后我们会看到一个这样的例子。

When in doubt, use the term “test double” or “fake.” Often, a single fake dependency can be used as a stub in one test, and it can be used as a mock in another test. We’ll see an example of this later on.

XUnit 测试模式和命名事物

XUnit test patterns and naming things

Gerard Meszaros 的《xUnit 测试模式:重构测试代码》(Addison-Wesley,2007 年)是单元测试的经典模式参考书。它至少以五种方式定义了您在测试中伪造的事物的模式。一旦您对我在这里提到的三种类型有了一定的了解,我鼓励您查看本书提供的额外详细信息。

xUnit Test Patterns: Refactoring Test Code by Gerard Meszaros (Addison-Wesley, 2007) is a classic pattern reference book for unit testing. It defines patterns for things you fake in your tests in at least five ways. Once you’ve gotten a feel for the three types I mention here, I encourage you to take a look at the extra details that book provides.

请注意,xUnit 测试模式对“fake”一词有定义:“用更轻量级的实现替换被测系统 (SUT) 所依赖的组件。” 例如,您可以使用内存数据库而不是成熟的生产实例。

Note that xUnit Test Patterns has a definition for the word “fake”: “Replace a component that the system under test (SUT) depends on with a much lighter-weight implementation.” For example, you might use an in-memory database instead of a full-fledged production instance.

我仍然认为这种类型的测试替身是一个“桩”,并且我使用“假”这个词来指出任何不真实的东西,就像术语“测试替身”一样,但“假”在实际应用中更短、更容易。舌头。

I still consider this type of test double a “stub,” and I use the word “fake” to call out anything that isn’t real, much like the term “test double,” but “fake” is shorter and easier on the tongue.

这看起来似乎同时包含了很多信息。我将在本章中深入探讨这些定义。让我们先从桩开始

This might seem like a whole lot of information at once. I’ll dive deep into these definitions throughout this chapter. Let’s take a small bite and start with stubs.

3.2 使用桩的原因

3.2 Reasons to use stubs

如果我们面临测试如下代码的任务怎么办?

What if we’re faced with the task of testing a piece of code like the following?

清单3.1verifyPassword使用时间

Listing 3.1 verifyPassword using time

const moment = require('moment');
const 周日 = 0,周六 = 6;
 
const verifyPassword = (输入, 规则) => {
    const dayOfWeek = moment().day() ;
    if ([周六、周日].includes(dayOfWeek)) {
        抛出错误(“这是周末!”);
    }
    //更多代码在这里...
    //返回发现的错误列表..
    返回 [];
};
const moment = require('moment');
const SUNDAY = 0, SATURDAY = 6;
 
const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

我们的密码验证器有一个新的依赖项:它无法在周末工作。去搞清楚。具体来说,该模块直接依赖于 moment.js,这是 JavaScript 的一个非常常见的日期/时间包装器。直接在 JavaScript 中处理日期并不是一种愉快的体验,因此我们可以假设许多商店都有类似的东西。

Our password verifier has a new dependency: it can’t work on weekends. Go figure. Specifically, the module has a direct dependency on moment.js, which is a very common date/time wrapper for JavaScript. Working with dates directly in JavaScript is not a pleasant experience, so we can assume many shops out there have something like this.

直接使用与时间相关的库对我们的单元测试有何影响?这里不幸的问题是,这种直接依赖性迫使我们的测试考虑正确的日期和时间,因为没有直接的方法来影响被测应用程序内的日期和时间。以下清单显示了一个不幸的测试,该测试仅在周末运行。

How does this direct use of a time-related library affect our unit tests? The unfortunate issue here is that this direct dependency forces our tests, given no direct way to affect date and time inside our application under test, to take into account the correct date and time. The following listing shows an unfortunate test that only runs on weekends.

清单 3.2 初始单元测试verifyPassword

Listing 3.2 Initial unit tests for verifyPassword

const moment = require('moment');
const {verifyPassword} = require("./password-verifier-time00");
const 周日 = 0,周六 = 6,周一 = 2;
 
描述('验证者', () => {
    const TODAY = moment().day();
 
    //测试总是被执行,但可能不会做任何事情
    test('周末,抛出异常', () => {
        if ([周六、周日].includes(今天)) {        
            期望(()=>验证密码('任何东西',[]))
                .toThrow("周末到了!");
        }
    });
 
    //测试甚至不在工作日执行
    if ([周六、周日].includes(今天)) {            
        test('周末,抛出错误', () => {
            期望(()=> verifyPassword('任何东西',[]))
                .toThrow("周末到了!");
        });
    } 
});
const moment = require('moment');
const {verifyPassword} = require("./password-verifier-time00");
const SUNDAY = 0, SATURDAY = 6, MONDAY = 2;
 
describe('verifier', () => {
    const TODAY = moment().day();
 
    //test is always executed, but might not do anything
    test('on weekends, throws exceptions', () => {
        if ([SATURDAY, SUNDAY].includes(TODAY)) {       
            expect(()=> verifyPassword('anything',[]))
                .toThrow("It's the weekend!");
        }
    });
 
    //test is not even executed on week days
    if ([SATURDAY, SUNDAY].includes(TODAY)) {           
        test('on a weekend, throws an error', () => {
            expect(()=> verifyPassword('anything', []))
                .toThrow("It's the weekend!");
        });
    }
});

检查测试内的日期

Checking the date inside the test

检查考试外的日期

Checking the date outside the test

前面的清单包括同一测试的两个变体。一个在测试内检查当前日期,另一个在测试检查,这意味着除非是周末,否则测试甚至不会执行。这不好。

The preceding listing includes two variations on the same test. One checks for the current date inside the test, and the other has the check outside the test, which means the test never even executes unless it’s the weekend. This is bad.

让我们回顾一下第一章中提到的良好测试品质之一:一致性:每次我运行测试时,它都与我之前运行的测试完全相同。所使用的值不会改变。断言不会改变。如果没有代码发生更改(在测试或生产代码中),则测试应提供与之前运行完全相同的结果。

Let’s revisit one of the good test qualities mentioned in chapter 1, consistency: Every time I run a test, it is the same exact test that I ran before. The values being used do not change. The asserts do not change. If no code has changed (in test or production code), then the test should provide the exact same result as previous runs.

第二个测试有时甚至无法运行。这是使用伪造品来打破依赖性的充分理由。此外,我们无法模拟周末或工作日,这给了我们足够的动力来重新设计被测试的代码,因此它更容易注入依赖项。

The second test sometimes doesn’t even run. That’s a good enough reason to use a fake to break the dependency right there. Furthermore, we can’t simulate a weekend or a weekday, which gives us more than enough incentive to redesign the code under test so it’s a bit more injectable for dependencies.

但等等,还有更多。使用时间的测试通常是不稳定的。他们只是有时会失败,除了时间的变化之外什么也没有。该测试是这种行为的主要候选者,因为当我们在本地运行它时,我们只会获得有关其两个状态之一的反馈如果您想知道周末的表现如何,只需等待几天即可。啊。

But wait, there’s more. Tests that use time can often be flaky. They only fail sometimes, without anything but the time changing. This test is a prime candidate for this behavior, because we’ll only get feedback on one of its two states when we run it locally. If you want to know how it behaves on a weekend, just wait a couple of days. Ugh.

由于影响测试中不受我们控制的变量的边缘情况,测试可能会变得不稳定。常见的示例是端到端测试期间的网络问题、数据库连接问题或各种服务器问题。当发生这种情况时,很容易通过说“再次运行它”或“没关系”来消除测试失败。这只是[在此处插入可变性问题]。”

Tests might become flaky due to edge cases that affect variables that are not under our control in the test. Common examples are network issues during end-to-end testing, database connectivity issues, or various server issues. When this happens, it’s easy to dismiss the test failure by saying “just run it again” or “It’s OK. It’s just [insert variability issue here].”

3.3 普遍接受的桩设计方法

3.3 Generally accepted design approaches to stubbing

在接下来的几节中,我们将讨论将桩注入工作单元的几种常见形式。首先,我们将讨论基本参数化作为第一步,然后我们将跳转到以下方法:

In the next few sections, we’ll discuss several common forms of injecting stubs into our units of work. First, we’ll discuss basic parameterization as a first step, then we’ll jump into the following approaches:

  • 功能性方法

    • 函数作为参数

    • 部分应用(柯里化)

    • 工厂功能

    • 构造函数

  • Functional approaches

    • Function as parameter

    • Partial application (currying)

    • Factory functions

    • Constructor functions

  • 模块化方法

    • 模块注入

  • Modular approach

    • Module injection

  • 面向对象的方法

    • 类构造函数注入

    • 对象作为参数(又名鸭子类型)

    • 通用接口作为参数(为此我们将使用 TypeScript)

  • Object-oriented approaches

    • Class constructor injection

    • Object as parameter (aka duck typing)

    • Common interface as parameter (for this we’ll use TypeScript)

我们将从测试中控制时间的简单情况开始解决每个问题。

We’ll tackle each of these by starting with the simple case of controlling time in our tests.

3.3.1 通过参数注入消除时间

3.3.1 Stubbing out time with parameter injection

根据我们到目前为止所讨论的内容,我至少可以想到两个控制时间的好理由:

I can think of at least two good reasons to control time based on what we’ve covered so far:

  • 消除我们测试中的可变性

  • To remove the variability from our tests

  • 为了轻松模拟任何与时间相关的场景,我们想测试我们的代码

  • To easily simulate any time-related scenario we’d like to test our code with

这是我能想到的最简单的重构,它使事情更具可重复性。让我们currentDay向函数添加一个参数来指定当前日期。这将消除在我们的函数中使用 moment.js 模块的需要,并将该责任交给函数的调用者。这样,在我们的测试中,我们可以以硬编码的方式确定时间,并使测试和函数可重复且一致。以下清单显示了此类重构的示例。

Here’s the simplest refactoring I can think of that makes things a bit more repeatable. Let’s add a currentDay parameter to our function to specify the current date. This will remove the need to use the moment.js module in our function, and it will put that responsibility on the caller of the function. That way, in our tests, we can determine the time in a hardcoded manner and make the test and the function repeatable and consistent. The following listing shows an example of such a refactoring.

清单 3.3verifyPasswordcurrentDay参数

Listing 3.3 verifyPassword with a currentDay parameter

const verifyPassword2 = (输入, 规则, currentDay ) => {
    if ([星期六, 星期日].includes( currentDay )) {
        抛出错误(“这是周末!”);
    }
    //更多代码在这里...
    //返回发现的错误列表..
    返回 [];
};
 
const 周日 = 0,周六 = 6 ,周一 = 1 ;
描述('verifier2 - 虚拟对象', () => {
    test('周末,抛出异常', () => {
        Expect(() => verifyPassword2 ('任何东西',[], SUNDAY ))
            .toThrow("周末到了!");
    });
});
const verifyPassword2 = (input, rules, currentDay) => {
    if ([SATURDAY, SUNDAY].includes(currentDay)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};
 
const SUNDAY = 0, SATURDAY = 6, MONDAY = 1;
describe('verifier2 - dummy object', () => {
    test('on weekends, throws exceptions', () => {
        expect(() => verifyPassword2('anything',[],SUNDAY ))
            .toThrow("It's the weekend!");
    });
});

通过添加currentDay参数,我们实质上将时间控制权交给了函数的调用者(我们的测试)。我们注入的内容正式称为“虚拟”——它只是一段没有行为的数据——但从现在开始我们可以将其称为“桩”。

By adding the currentDay parameter, we’re essentially giving control over time to the caller of the function (our test). What we’re injecting is formally called a “dummy”—it’s just a piece of data with no behavior—but we can call it a “stub” from now on.

这种方法是依赖倒置的一种形式。术语“控制反转”似乎首次出现在 Johnson 和 Foote于 1988 年在《面向对象编程杂志》上发表的论文“设计可重用类”中。术语“依赖反转”也是 SOLID 描述的模式之一。 Robert C. Martin 2000 年在他的“设计原则和设计模式”论文中。我将在第 8 章中详细讨论更高层次的设计注意事项。

This is approach is a form of Dependency Inversion. It seems the term “Inversion of Control” first came up in Johnson and Foote’s paper “Designing Reusable Classes,” published by the Journal of Object-Oriented Programming in 1988. The term “Dependency Inversion” is also one of the SOLID patterns described by Robert C. Martin in 2000, in his “Design Principles and Design Patterns” paper. I’ll talk more about higher-level design considerations in chapter 8.

添加这个参数是一个简单的重构,但是非常有效。除了测试的一致性之外,它还提供了一些不错的好处:

Adding this parameter is a simple refactoring, but it’s quite effective. It provides a couple of nice benefits other than consistency in the test:

  • 我们现在可以轻松模拟我们想要的任何一天。

  • We can now easily simulate any day we want.

  • 被测试的代码不负责管理时间导入,因此如果我们使用不同的时间库,它就没有理由进行更改。

  • The code under test is not responsible for managing time imports, so it has one less reason to change if we ever use a different time library.

我们正在将时间“依赖注入”到我们的工作单元中。我们更改了入口点的设计,以使用日期值作为参数。按照函数式编程标准,该函数现在是“纯粹的”,因为它没有副作用。纯函数具有所有依赖项的内置注入,这是您会发现函数式编程设计通常更容易测试的原因之一。

We’re doing “dependency injection” of time into our unit of work. We’ve changed the design of the entry point to use a day value as a parameter. The function is now “pure” by functional programming standards in that it has no side effects. Pure functions have built-in injections of all of their dependencies, which is one of the reasons you’ll find functional programming designs are typically much easier to test.

03-02



图 3.2 为时间依赖性注入桩

Figure 3.2 Injecting a stub for a time dependency

currentDay如果参数只是一天的整数值,那么将参数称为桩可能会感觉很奇怪,但根据xUnit 测试模式的定义,我们可以说这是一个“虚拟”值,就我而言,它属于“桩”类别。它不必很复杂才能成为桩。它只需要在我们的控制之下。它是一个桩,因为我们使用它来模拟传递被测单元的某些输入或行为。图 3.2 直观地展示了这一点。

It might feel weird to call the currentDay parameter a stub if it’s just a day integer value, but based on the definitions from xUnit Test Patterns, we can say that this is a “dummy” value, and as far as I’m concerned, it falls into the “stub” category. It does not have to be complex in order to be a stub. It just has to be under our control. It’s a stub because we are using it to simulate some input or behavior being passed into the unit under test. Figure 3.2 shows this visually.

3.3.2 依赖、注入和控制

3.3.2 Dependencies, injections, and control

表 3.2 回顾了我们已经讨论过的一些重要术语,并将在本章的其余部分中使用。

Table 3.2 recaps some important terms we’ve discussed and are about to use throughout the rest of the chapter.

表 3.2 本章使用的术语

Table 3.2 Terminology used in this chapter

依赖关系

Dependencies

那些使我们的测试寿命和代码可维护性变得困难的事情,因为我们无法从测试中控制它们。示例包括时间、文件系统、网络、随机值等等。

The things that make our testing lives and code maintainability difficult, since we cannot control them from our tests. Examples include time, the filesystem, the network, random values, and more.

控制

Control

指示依赖项如何行为的能力。据称,创建依赖项的人可以控制它们,因为他们能够在测试代码中使用它们之前对其进行配置。

The ability to instruct a dependency how to behave. Whoever is creating the dependencies is said to be in control of them, since they have the ability to configure them before they are used in the code under test.

在清单 3.1 中,我们的测试无法控制时间因为被测模块可以控制时间。该模块选择始终使用当前日期和时间。这迫使测试做完全相同的事情,因此我们失去了测试的一致性。

In listing 3.1, our test does not have control over time because the module under test has control over it. The module has chosen to always use the current date and time. This forces the test to do the exact same thing, and thus we lose consistency in our tests.

在清单 3.3 中,我们通过参数反转对依赖项的控制来访问依赖项currentDay。现在测试可以控制时间并可以决定使用硬编码时间。被测模块必须使用提供的时间,这使我们的测试变得更加容易。

In listing 3.3, we have gained access to the dependency by inverting the control over it via the currentDay parameter. Now the test has control over the time and can decide to use a hardcoded time. The module under test has to use the time provided, which makes things much easier for our test.

控制反转

Inversion of control

设计代码以消除在内部创建依赖项的责任,并将其外部化。清单 3.3 显示了一种通过参数 注入来实现此目的的方法。

Designing the code to remove the responsibility of creating the dependency internally, and externalizing it instead. Listing 3.3 shows one way of doing this with parameter injection.

依赖注入

Dependency injection

通过设计接口发送依赖项以供一段代码在内部使用的行为。注入依赖的地方就是注入点。在我们的例子中,我们使用参数注入点。我们可以注入东西的地方的另一个词是接缝

The act of sending a dependency through the design interface to be used internally by a piece of code. The place where you inject the dependency is the injection point. In our case, we are using a parameter injection point. Another word for this place where we can inject things is a seam.

接缝

Seam

发音为“s-ee-m”,由 Michael Feathers 在他的著作《有效处理遗留代码》(Pearson,2004 年)中创造。

Pronounced “s-ee-m,” and coined by Michael Feathers in his book Working Effectively with Legacy Code (Pearson, 2004).

接缝是两个软件相遇的地方,可以注入其他东西。您可以在其中更改程序的行为,而无需在该位置进行编辑。示例包括参数、函数、模块加载器、函数重写,以及面向对象世界中的类接口、公共虚拟方法等等。

Seams are where two pieces of software meet and something else can be injected. They are a place where you can alter behavior in your program without editing in that place. Examples include parameters, functions, module loaders, function rewriting, and, in the object-oriented world, class interfaces, public virtual methods, and more.

生产代码中的接缝对于单元测试的可维护性和可读性起着重要作用。更改行为或自定义数据并将其注入到被测代码中越容易,随着生产代码更改,编写、读取和维护测试就越容易。我将在第 8 章中详细讨论与设计代码相关的一些模式和反模式。

Seams in production code play an important role in the maintainability and readability of unit tests. The easier it is to change and inject behavior or custom data into the code under test, the easier it will be to write, read, and later on maintain the test as the production code changes. I’ll talk more about some patterns and antipatterns related to designing code in chapter 8.

3.4 功能注入技术

3.4 Functional injection techniques

此时,我们可能对我们的设计选择不满意。添加参数确实解决了函数级别的依赖性问题,但现在每个调用者都需要知道如何以某种方式处理日期。感觉有点太啰嗦了。

At this point, we might not be happy with our design choice. Adding a parameter did solve the dependency issue at the function level, but now every caller will need to know how to handle dates in some way. It feels a bit too chatty.

JavaScript 支持两种主要的编程风格——函数式和面向对象——所以我将在有意义的时候展示这两种风格的方法,并且您可以选择最适合您情况的方法。

JavaScript enables two major styles of programming—functional and object-oriented—so I’ll show approaches in both styles when it makes sense, and you can pick and choose what works best in your situation.

设计东西没有单一的方法。函数式编程的支持者会主张函数式风格的简单性、清晰性和可证明性,但它确实有一个学习曲线。仅出于这个原因,学习这两种方法是明智的,这样您就可以应用最适合您正在合作的团队的方法。有些团队会更倾向于面向对象的设计,因为他们对此感到更舒服。其他人会倾向于功能性设计。我认为这些模式基本上是相同的。我们只是将它们翻译成不同的风格。

There isn’t a single way to design something. Functional programming proponents will argue for the simplicity, clarity, and provability of the functional style, but it does come with a learning curve. For that reason alone, it is wise to learn both approaches so that you can apply whichever works best for the team you’re working with. Some teams will lean more toward object-oriented designs because they feel more comfortable with that. Others will lean towards functional designs. I’d argue that the patterns remain largely the same; we just translate them to different styles.

3.4.1 注入函数

3.4.1 Injecting a function

下面的清单显示了针对同一问题的不同重构:我们期望函数作为参数,而不是数据对象。该函数返回日期对象。

The following listing shows a different refactoring for the same problem: instead of a data object, we’re expecting a function as the parameter. That function returns the date object.

清单 3.4 使用函数进行依赖注入

Listing 3.4 Dependency injection with a function

const verifyPassword3 = (输入, 规则, getDayFn ) => {
    const dayOfWeek = getDayFn() ;
    if ([周六、周日].includes(dayOfWeek)) {
        抛出错误(“这是周末!”);
    }
    //更多代码在这里...
    //返回发现的错误列表..
    返回 [];
};
const verifyPassword3 = (input, rules, getDayFn) => {
    const dayOfWeek = getDayFn();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

相关测试如下面的清单所示。

The associated test is shown in the following listing.

清单 3.5 使用函数注入进行测试

Listing 3.5 Testing with function injection

描述('verifier3 - 虚拟函数', () => {
    test('周末,抛出异常', () => {
        const 总是星期日 = () => 星期日; 
        期望(()=> verifyPassword3('任何东西',[],alwaysSunday))
            .toThrow("周末到了!");
    });
describe('verifier3 - dummy function', () => {
    test('on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        expect(()=> verifyPassword3('anything',[], alwaysSunday))
            .toThrow("It's the weekend!");
    });

与之前的测试没有什么区别,但是使用函数作为参数是进行注入的有效方法。在其他场景中,这也是启用特殊行为的好方法,例如在测试的代码中模拟特殊情况或异常。

There’s very little difference from the previous test, but using a function as a parameter is a valid way to do injection. In other scenarios, it’s also a great way to enable special behavior, such as simulating special cases or exceptions in your code under test.

3.4.2 通过部分应用程序进行依赖注入

3.4.2 Dependency injection via partial application

工厂函数或方法(“高阶函数”的子类别)是返回其他函数的函数,并预先配置了一些上下文。在我们的例子中,上下文可以是规则列表和当天的函数。然后,我们返回一个新函数,只需输入字符串即可触发该函数,它将使用getDay()在其创建时配置的规则和函数。

Factory functions or methods (a subcategory of “higher-order functions”) are functions that return other functions, preconfigured with some context. In our case, the context can be the list of rules and the current day function. We then get back a new function that we can trigger with only a string input, and it will use the rules and getDay() function configured in its creation.

以下清单中的代码本质上将工厂函数转换为测试的安排部分,并将返回的函数调用为行动部分。相当可爱。

The code in the following listing essentially turns the factory function into the arrange part of the test, and calls the returned function into the act part. Quite lovely.

清单 3.6 使用高阶工厂函数

Listing 3.6 Using a higher-order factory function

常量星期日 = 0, . 。。周五=5,周六=6;
 
const makeVerifier = (rules, dayOfWeekFn) => {
    返回函数 (输入) {
        if ([周六、周日].includes(dayOfWeekFn())) {
            抛出新的错误(“这是周末!”);
        }
        //更多代码在这里..
    }; 
};
 
描述('验证者', () => {
    test('工厂方法:周末,抛出异常', () => {
        const 总是星期日 = () => 星期日;
        const verifyPassword = makeVerifier([], alwaysSunday);
 
        期望(()=>验证密码('任何东西'))
            .toThrow("周末到了!");
    });
const SUNDAY = 0, . . . FRIDAY=5, SATURDAY = 6;
 
const makeVerifier = (rules, dayOfWeekFn) => {
    return function (input) {
        if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
            throw new Error("It's the weekend!");
        }
        //more code goes here..
    };
};
 
describe('verifier', () => {
    test('factory method: on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        const verifyPassword = makeVerifier([], alwaysSunday);
 
        expect(() => verifyPassword('anything'))
            .toThrow("It's the weekend!");
    });

3.5 模块化注入技术

3.5 Modular injection techniques

JavaScript 还允许模块的概念,我们importrequire. 当我们的测试代码中直接导入依赖项时,例如清单 3.1 中的代码(此处再次显示),我们如何处理依赖项注入的想法?

JavaScript also allows for the idea of modules, which we import or require. How can we handle the idea of dependency injection when faced with a direct import of a dependency in our code under test, such as in the code from listing 3.1, shown again here?

const moment = require('moment');
周日 = 0; 常量星期六 = 6;
 
const verifyPassword = (输入, 规则) => {
    const dayOfWeek = moment() .day();
    if ([周六、周日].includes(dayOfWeek)) {
        抛出错误(“这是周末!”);
    }
    // 这里还有更多代码...
    // 返回发现的错误列表..
    返回 [];
};
const moment = require('moment');
const SUNDAY = 0; const SATURDAY = 6;
 
const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    // more code goes here...
    // return list of errors found..
    return [];
};

我们怎样才能克服这种直接依赖呢?答案是,我们不能。我们必须以不同的方式编写代码,以便稍后替换该依赖项。我们必须创建一个接缝,通过它我们可以替换我们的依赖项。这是一个这样的例子。

How can we overcome this direct dependency that’s happening? The answer is, we can’t. We’ll have to write the code differently to allow for the replacement of that dependency later on. We’ll have to create a seam through which we can replace our dependencies. Here’s one such example.

清单 3.7 抽象所需的依赖项

Listing 3.7 Abstracting the required dependencies

const originDependency = {                         
    moment: require('moment'),                         
};                                                    
 
让依赖项 = { ...originalDependency };       
 
const Inject = (fakes) => {                            
    Object.assign(dependency, fakes); 
    返回函数重置(){                          
        依赖项={...originalDependencies}; 
    } 
};
 
周日 = 0; 常量星期六 = 6;
 
const verifyPassword = (输入, 规则) => {
    const dayOfWeek =依赖项。时刻().day();
    if ([周六、周日].includes(dayOfWeek)) {
        抛出错误(“这是周末!”);
    }
    // 这里还有更多代码...
    // 返回发现的错误列表..
    返回 [];
};
 
模块. 导出 = {
    周六,
    验证密码,
    注入
};
const originalDependencies = {                        
    moment: require(‘moment’),                        
};                                                    
 
let dependencies = { ...originalDependencies };       
 
const inject = (fakes) => {                           
    Object.assign(dependencies, fakes);
    return function reset() {                         
        dependencies = { ...originalDependencies };
    }
};
 
const SUNDAY = 0; const SATURDAY = 6;
 
const verifyPassword = (input, rules) => {
    const dayOfWeek = dependencies.moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    // more code goes here...
    // return list of errors found..
    return [];
};
 
module.exports = {
    SATURDAY,
    verifyPassword,
    inject
};

用中间对象包装 moment.js

Wrapping moment.js with an intermediary object

包含当前依赖的对象,无论是真实的还是假的

The object containing the current dependency, either real or fake

一种用假依赖替换真实依赖的函数

A function that replaces the real dependency with a fake one

一种将依赖关系重置回真实依赖关系的函数

A function that resets the dependency back to the real one

这里发生了什么?引入了三项新内容:

What’s going on here? Three new things have been introduced:

  • 首先,我们用一个对象替换了对 moment.js 的直接依赖:originalDependencies。它包含该模块导入作为其实现的一部分。

  • First, we have replaced our direct dependency on moment.js with an object: originalDependencies. It contains that module import as part of its implementation.

  • 接下来,我们在组合中添加了另一个对象:dependencies。默认情况下,该对象承担该originalDependencies对象包含的所有实际依赖项。

  • Next, we have added yet another object into the mix: dependencies. This object, by default, takes on all of the real dependencies that the originalDependencies object contains.

  • 最后,该inject函数(我们也将其作为我们自己的模块的一部分公开)允许导入我们的模块(生产代码和测试)的任何人用自定义依赖项(假项)覆盖我们的真实依赖项。

  • Finally, the inject function, which we’re also exposing as part of our own module, allows whoever is importing our module (both production code and tests) to override our real dependencies with custom dependencies (fakes).

当您调用 时inject,它会返回一个reset函数,该函数将原始依赖项重新应用到当前dependencies变量上,从而重置当前使用的所有伪造品。

When you invoke inject, it returns a reset function that reapplies the original dependencies onto the current dependencies variable, thus resetting any fakes currently being used.

以下是如何在测试中使用inject和函数。reset

Here’s how you can use the inject and reset functions in a test.

清单 3.8 注入一个假模块inject()

Listing 3.8 Injecting a fake module with inject()

const {注入, verifyPassword, 星期六 } = require('./password-verifier-time00-modular');
 
constjectDate=(newDay)=>{                     ❶constreset 
    =inject({                           
        时刻:function(){
            //我们在这里伪造了 moment.js 模块的 API。
            返回 {
                天: () => newDay 
            } 
        } 
    }); 
    返回重置;
};
 
描述('验证密码',()=> {
    描述('什么时候是周末', () => {
        it('抛出错误', () => {
            const重置=注入日期(星期六);     
 
            Expect(() => verifyPassword('任何输入'))
                .toThrow("周末到了!");
 
            重置();                                
        });
    });
});
const { inject, verifyPassword, SATURDAY } = require('./password-verifier-time00-modular');
 
const injectDate = (newDay) => {                    
    const reset = inject({                          
        moment: function () {
            //we're faking the moment.js module's API here.
            return {
                day: () => newDay
            }
        }
    });
    return reset;
};
 
describe('verifyPassword', () => {
    describe('when its the weekend', () => {
        it('throws an error', () => {
            const reset = injectDate(SATURDAY);     
 
            expect(() => verifyPassword('any input'))
                .toThrow("It's the weekend!");
 
            reset();                                
        });
    });
});

辅助函数

A helper function

注入假 API 而不是 moment.js

Injecting a fake API instead of moment.js

提供一个假日子

Providing a fake day

重置依赖关系

Resetting the dependency

让我们来分析一下这里发生了什么:

Let’s break down what’s going on here:

  1. injectDate函数只是一个辅助函数,旨在减少我们测试中的样板代码。它总是构建 moment.js API 的虚假结构,并将其getDay函数设置为返回newDay参数。

  2. The injectDate function is just a helper function meant to reduce the boilerplate code in our test. It always builds the fake structure of the moment.js API, and it sets its getDay function to return the newDay parameter.

  3. injectDate函数inject使用新的 fake moment.js API 进行调用。这将我们工作单元中的假依赖关系应用到我们作为参数发送的依赖关系。

  4. The injectDate function calls inject with the new fake moment.js API. This applies the fake dependency in our unit of work to the one we have sent in as a parameter.

  5. 我们的测试inject使用自定义的假日期调用该函数。

  6. Our test calls the inject function with a custom, fake day.

  7. 在测试结束时,我们调用该reset函数,该函数将工作单元的模块依赖关系重置为原始依赖关系。

  8. At the end of the test, we call the reset function, which resets the unit of work’s module dependencies to the original ones.

一旦你这样做了几次,它就开始有意义了。但它也有一些警告。从专业角度来看,它确实解决了我们测试中的依赖问题,并且相对易于使用。至于缺点,据我所知,有一个巨大的缺点。使用此方法来伪造我们的模块化依赖项会迫使我们的测试与我们所伪造的依赖项的 API 签名紧密相关。如果这些是第三方依赖项,例如 moment.js、记录器或我们无法完全控制的其他任何东西,那么当需要升级或用某些东西替换依赖项时(一如既往),我们的测试将变得非常脆弱具有不同的 API。如果只是一两个测试,这不会造成太大伤害,但我们通常会有数百或数千个测试,这些测试必须伪造几个常见的依赖项,这有时意味着在用破坏性的记录器替换记录器时更改和修复数百个文件例如,API 更改。

Once you’ve done this a couple of times, it starts making sense. But it has some caveats as well. On the pro side, it definitely takes care of the dependency issue in our tests, and it’s relatively easy to use. As for the cons, there is one huge downside as far as I can see. Using this method to fake our modular dependencies forces our tests to be closely tied to the API signature of the dependencies we are faking. If these are third-party dependencies, such as moment.js, loggers, or anything else that we do not fully control, our tests will become very brittle when the time comes (as it always does) to upgrade or replace the dependencies with something that has a different API. This doesn’t hurt much if it’s just a test or two, but we’ll usually have hundreds or thousands of tests that have to fake several common dependencies, and that sometimes means changing and fixing hundreds of files when replacing a logger with a breaking API change, for example.

我有两种可能的方法来防止这种情况发生:

I have two possible ways to prevent such a situation:

  • 切勿导入您无法直接在代码中控制的第三方依赖项。始终使用您可以控制的临时抽象。端口和适配器架构是这种想法的一个很好的例子(该架构的其他名称是六角形架构和洋葱架构)。有了这样的架构,伪造这些内部 API 的风险应该会更小,因为我们可以控制它们的变化率,从而使我们的测试不那么脆弱。(即使外部世界发生变化,我们也可以在内部重构它们,而无需我们的测试关心。)

  • Never import a third-party dependency that you don’t control directly in your code. Always use an interim abstraction that you do control. The Ports and Adapters architecture is a good example of such an idea (other names for this architecture are Hexagonal architecture and Onion architecture). With such an architecture, faking these internal APIs should present less risk, because we can control their rate of change, thus making our tests less brittle. (We can refactor them internally without our tests caring, even if the outside world changes.)

  • 避免使用模块注入,而是使用本书中提到的其他方法之一进行依赖项注入:函数参数、柯里化以及下一节中提到的构造函数和接口。在这些之间,您应该有很多选择,而不是直接导入东西。

  • Avoid using module injection, and instead use one of the other ways mentioned in this book for dependency injection: function parameters, currying, and, as mentioned in the next section, constructors and interfaces. Between these, you should have plenty of choices instead of importing things directly.

3.6 转向具有构造函数的对象

3.6 Moving toward objects with constructor functions

构造函数是一种稍微更面向对象的 JavaScript 方式,可以实现与工厂函数相同的结果,但它们返回类似于带有我们可以触发的方法的对象的东西。然后我们使用关键字new调用该函数并返回该特殊对象。

Constructor functions are a slightly more object-oriented JavaScript-ish way of achieving the same result as a factory function, but they return something akin to an object with methods we can trigger. We then use the keyword new to call this function and get back that special object.

以下是使用此设计选择时相同的代码和测试的样子。

Here’s what the same code and tests look like with this design choice.

清单 3.9 使用构造函数

Listing 3.9 Using a constructor function

const Verifier = 函数(rules, dayOfWeekFn) 
{ 
    this.verify = 函数(输入) {
        if ([周六、周日].includes(dayOfWeekFn())) {
            抛出新的错误(“这是周末!”);
        }
        //更多代码在这里..
    }; 
};
 
const {Verifier} = require("./password-verifier-time01");
 
test('构造函数:周末,抛出异常', () => {
    const 总是星期日 = () => 星期日;
    const verifier = new Verifier([], alwaysSunday);
 
    Expect(() => verifier.verify('anything') )
        .toThrow("周末到了!");
});
const Verifier = function(rules, dayOfWeekFn)
{
    this.verify = function (input) {
        if ([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
            throw new Error("It's the weekend!");
        }
        //more code goes here..
    };
};
 
const {Verifier} = require("./password-verifier-time01");
 
test('constructor function: on weekends, throws exception', () => {
    const alwaysSunday = () => SUNDAY;
    const verifier = new Verifier([], alwaysSunday);
 
    expect(() => verifier.verify('anything'))
        .toThrow("It's the weekend!");
});

你可能会看到这个并问:“为什么要朝着物体移动?” 答案实际上取决于您当前项目的背景、其堆栈、您的团队对函数式编程和面向对象背景的了解,以及许多其他非技术因素。最好将此工具放在您的工具箱中,这样您就可以在需要时使用它。当您阅读接下来的几节时,请牢记这一点。

You might look at this and ask, “Why move toward objects?” The answer really depends on the context of your current project, its stack, your team’s knowledge of functional programming and object-oriented background, and many other non-technical factors. It’s good to have this tool in your toolbox so you can use it when it makes sense to you. Keep this in the back of your mind as you read the next few sections.

3.7 面向对象注入技术

3.7 Object-oriented injection techniques

如果您倾向于更面向对象的风格,或者您正在使用 C# 或 Java 等面向对象语言,那么这里有一些在面向对象世界中广泛使用的常见模式依赖注入。

If a more object-oriented style is what you’re leaning toward, or if you’re working in an object-oriented language such as C# or Java, here are a few common patterns that are widely used in the object-oriented world for dependency injection.

3.7.1 构造函数注入

3.7.1 Constructor injection

构造函数注入是我描述一种设计的方式,在该设计中我们可以通过类的构造函数注入依赖项。在 JavaScript 世界中,Angular 是最著名的 Web 前端框架,它使用这种设计来注入“服务”,这只是 Angular 语言中“依赖项”的代号。在许多其他情况下,这是一种可行的设计。

Constructor injection is how I would describe a design in which we can inject dependencies through the constructor of a class. In the JavaScript world, Angular is the best-known web frontend framework that uses this design for injecting “services,” which is just a code word for “dependencies” in Angular-speak. This is a viable design in many other situations.

拥有一个有状态的类并不是没有好处的。它可以消除客户端的重复,只需要配置我们的类一次,然后可以多次重用配置的类。

Having a stateful class is not without benefits. It can remove repetition from clients that only need to configure our class once and can then reuse the configured class multiple times.

如果我们选择创建有状态版本的密码验证器,并且希望通过构造函数注入来注入日期函数,则它可能类似于以下设计。

If we had chosen to create a stateful version of Password Verifier, and we wanted to inject the date function through constructor injection, it might look like the following design.

清单 3.10 构造函数注入设计

Listing 3.10 Constructor injection design

类PasswordVerifier {
    构造函数(rules, dayOfWeekFn) { 
        this.rules = 规则; 
        this.dayOfWeek = dayOfWeekFn; 
    }
 
    验证(输入){
        if ([星期六, 星期日].includes(this.dayOfWeek())) {
            抛出新的错误(“这是周末!”);
        }
        常量错误=[];
        //更多代码在这里..
        返回错误;
    };
}
 
test('类构造函数:周末,抛出异常', () => {
    const 总是星期日 = () => 星期日;
    constverifier= newPasswordVerifier([],alwaysSunday);
 
    Expect(() => verifier.verify('anything') )
        .toThrow("周末到了!");
});
class PasswordVerifier {
    constructor(rules, dayOfWeekFn) {
        this.rules = rules;
        this.dayOfWeek = dayOfWeekFn;
    }
 
    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.dayOfWeek())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}
 
test('class constructor: on weekends, throws exception', () => {
    const alwaysSunday = () => SUNDAY;
    const verifier = new PasswordVerifier([], alwaysSunday);
 
    expect(() => verifier.verify('anything'))
        .toThrow("It's the weekend!");
});

这看起来和感觉很像 3.6 节中的构造函数设计。这是一种更加面向类的设计,许多来自面向对象背景的人会觉得更舒服。它也更详细。你会发现我们制作的东西越面向对象,我们就会变得越来越冗长。它是面向对象游戏的一部分。这就是人们越来越多地选择功能性风格的部分原因——它们更加简洁。

This looks and feels a lot like the constructor function design in section 3.6. This is a more class-oriented design that many people will feel more comfortable with, coming from an object-oriented background. It also is more verbose. You’ll see that we get more and more verbose the more object-oriented we make things. It’s part of the object-oriented game. This is partly why people are choosing functional styles more and more—they are much more concise.

让我们谈谈测试的可维护性。如果我用这个类编写第二个测试,我会通过构造函数将类的创建提取到一个漂亮的小工厂函数中,该函数返回被测试类的实例,以便 if(即“when”)构造函数签名更改并立即破坏许多测试,我只需要修复一个地方即可使所有测试再次工作,如您在下面的清单中看到的。

Let’s talk a bit about the maintainability of the tests. If I wrote a second test with this class, I’d extract the creation of the class via the constructor to a nice little factory function that returns an instance of the class under test, so that if (i.e., “when”) the constructor signature changes and breaks many tests at once, I only have to fix a single place to get all the tests working again, as you can see in the following listing.

清单 3.11 在我们的测试中添加一个辅助工厂函数

Listing 3.11 Adding a helper factory function to our tests

describe('用构造函数重构', () => {
     const makeVerifier = (rules, dayFn) => { 
        return new PasswordVerifier(rules, dayFn); 
    };
 
    test('类构造函数:周末,抛出异常', () => {
        const 总是星期日 = () => 星期日;
        const verifier = makeVerifier([],alwaysSunday);
 
        Expect(() => verifier.verify('任何东西'))
            .toThrow("周末到了!");
    });
 
    test('类构造函数:平日里,没有规则,通过', () => {
        const 总是星期一 = () => 星期一;
        const verifier = makeVerifier([],alwaysMonday);
 
        const 结果 = verifier.verify('任何东西');
        期望(结果.长度).toBe(0);
    });
});
describe('refactored with constructor', () => {
    const makeVerifier = (rules, dayFn) => {
        return new PasswordVerifier(rules, dayFn);
    };
 
    test('class constructor: on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        const verifier = makeVerifier([],alwaysSunday);
 
        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
 
    test('class constructor: on weekdays, with no rules, passes', () => { 
        const alwaysMonday = () => MONDAY;
        const verifier = makeVerifier([],alwaysMonday);
 
        const result = verifier.verify('anything');
        expect(result.length).toBe(0);
    });
});

请注意,这与 3.4.2 节中的工厂功能设计不同。这个工厂函数驻留在我们的测试中;另一个在我们的生产代码中。这是为了测试可维护性,它可以与面向对象和功能性生产代码一起使用,因为它隐藏了函数或对象的创建或配置方式。它是我们测试中的抽象层,因此我们可以将对函数或对象如何创建或配置的依赖关系推送到测试中的单个位置。

Notice that this is not the same as the factory function design in section 3.4.2. This factory function resides in our tests; the other was in our production code. This one is for test maintainability, and it can work with object-oriented and functional production code because it hides how the function or object is being created or configured. It’s an abstraction layer in our tests, so we can push the dependency on how a function or object is created or configured into a single place in our tests.

3.7.2 注入对象而不是函数

3.7.2 Injecting an object instead of a function

现在,我们的类构造函数接受一个函数作为第二个参数:

Right now, our class constructor takes in a function as the second parameter:

构造函数(规则,dayOfWeekFn){
    this.rules = 规则;
    这。每周日=每周日 Fn ;
}
constructor(rules, dayOfWeekFn) {
    this.rules = rules;
    this.dayOfWeek = dayOfWeekFn;
}

让我们在面向对象的设计中更进一步,使用对象而不是函数作为参数。这需要我们做一些跑腿工作:重构代码。

Let’s go one step up in our object-oriented design and use an object instead of a function as our parameter. This requires us to do a bit of legwork: refactor the code.

首先,我们将创建一个名为 time-provider.js 的新文件,其中包含依赖于 moment.js 的真实对象。该对象将被设计为具有一个名为的函数getDay()

First, we’ll create a new file called time-provider.js, which will contain our real object that has a dependency on moment.js. The object will be designed to have a single function called getDay():

从“时刻”导入时刻;
 
const RealTimeProvider = () => {
    this.getDay = () => moment().day()
};
import moment from "moment";
 
const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};

接下来,我们将更改参数用法以使用带有函数的对象:

Next, we’ll change the parameter usage to use an object with a function:

const 周日 = 0,周一 = 1,周六 = 6;
类密码验证器 {
    构造函数(规则,时间提供者){
        this.rules = 规则;
        这。时间提供者=时间提供者;
    }
 
    验证(输入){
        if ([周六、周日].includes(this.timeProvider.getDay())) {
            抛出新的错误(“这是周末!”);
        }
    ...
}
const SUNDAY = 0, MONDAY = 1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }
 
    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
    ...
}

最后,让我们为任何需要我们实例的人PasswordVerifier提供默认情况下使用实时提供程序进行预配置的能力。我们将使用一个新函数来完成此passwordVerifierFactory操作,任何需要验证程序实例的生产代码都需要使用该函数:

Finally, let’s give whoever needs an instance of our PasswordVerifier the ability to get it preconfigured with the real time provider by default. We’ll do this with a new passwordVerifierFactory function that any production code that needs a verifier instance will need to use:

const passwordVerifierFactory = (规则) => {
    返回新的PasswordVerifier(new RealTimeProvider()) 
};
const passwordVerifierFactory = (rules) => {
    return new PasswordVerifier(new RealTimeProvider())
};

IoC 容器和依赖注入

IoC containers and dependency injection

还有许多其他方法可以粘合PasswordVerifier在一起TimeProvider。为了简单起见,我刚刚选择了手动注入。现在的许多框架都能够配置将依赖项注入到被测对象中,以便我们可以定义如何构造对象。Angular 就是这样的一个框架。

There are many other ways to glue PasswordVerifier and TimeProvider together. I’ve just chosen manual injection to keep things simple. Many frameworks today are able to configure the injection of dependencies into objects under test, so that we can define how an object is to be constructed. Angular is one such framework.

如果您使用 Java 中的 Spring 或 C# 中的 Autofac 或 StructureMap 等库,则可以通过构造函数注入轻松配置对象的构造,而无需创建专门的函数。通常,这些功能称为控制反转 (IoC) 容器或依赖注入 (DI) 容器。我不会在本书中使用它们,以避免不必要的细节。您不需要它们来创建出色的测试。

If you’re using libraries like Spring in Java or Autofac or StructureMap in C#, you can easily configure the construction of objects with constructor injection without needing to create specialized functions. Commonly, these features are called Inversion of Control (IoC) containers or Dependency Injection (DI) containers. I’m not using them in this book to avoid unneeded details. You don’t need them to create great tests.

事实上,我通常不会在测试中使用 IoC 容器。我几乎总是使用自定义工厂函数来注入依赖项。我发现这使得我的测试更容易阅读和推理。

In fact, I don’t normally use IoC containers in tests. I’ll almost always use custom factory functions to inject dependencies. I find that makes my tests easier to read and reason about.

即使对于涵盖 Angular 代码的测试,我们也不必通过 Angular 的 DI 框架将依赖项注入内存中的对象;我们可以直接调用该对象的构造函数并发送假的东西。只要我们在工厂函数中这样做,我们就不会牺牲可维护性,并且我们也不会向测试添加额外的代码,除非它对测试至关重要。

Even for tests covering Angular code, we don’t have to go through Angular’s DI framework to inject a dependency into an object in memory; we can call that object’s constructor directly and send in fake stuff. As long as we do that in a factory function, we’re not sacrificing maintainability, and we’re also not adding extra code to tests unless it’s essential to the tests.

以下清单显示了整段新代码。

The following listing shows the entire piece of new code.

清单 3.12 注入一个对象

Listing 3.12 Injecting an object

从“时刻”导入时刻;
 
const RealTimeProvider = () => {
    this.getDay = () => moment().day()
};
 
const 周日 = 0,周一 = 1,周六 = 6;
类密码验证器 {
    构造函数(规则,时间提供者){
        this.rules = 规则;
        这。时间提供者=时间提供者;
    }
 
    验证(输入){
        if ([星期六, 星期日].includes( this.timeProvider.getDay() )) {
            抛出新的错误(“这是周末!”);
        }
        常量错误=[];
        //更多代码在这里..
        返回错误;
    };
}
 
const passwordVerifierFactory = (规则) => {
    返回新的PasswordVerifier(new RealTimeProvider()) 
};
import moment from "moment";
 
const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};
 
const SUNDAY = 0, MONDAY=1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }
 
    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}
 
const passwordVerifierFactory = (rules) => {
    return new PasswordVerifier(new RealTimeProvider())
};

我们如何在测试中处理这种类型的设计,我们需要注入一个假对象,而不是一个假函数?我们首先会手动执行此操作,因此您会发现这没什么大不了的。稍后,我们将让框架帮助我们,但你会发现,有时手动编码假对象实际上可以使你的测试比使用框架更具可读性,例如 Jasmine、Jest 或 Sinon(我们将在第 1 章中介绍这些框架) 5)。

How can we handle this type of design in our tests, where we need to inject a fake object, instead of a fake function? We’ll do this manually at first, so you can see that it’s not a big deal. Later, we’ll let frameworks help us, but you’ll see that sometimes hand-coding fake objects can actually make your test more readable than using a framework, such as Jasmine, Jest, or Sinon (we’ll cover those in chapter 5).

首先,在我们的测试文件中,我们将创建一个新的假对象,它与我们的实时提供程序具有相同的函数签名,但它将由我们的测试控制。在这种情况下,我们将只使用构造函数模式:

First, in our test file, we’ll create a new fake object that has the same function signature as our real time provider, but it will be controllable by our tests. In this case, we’ll just use a constructor pattern:

函数 FakeTimeProvider( fakeDay ) {
    this.getDay = 函数 () {
        返回假日;
    }
}
function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}

注意如果您使用更面向对象的风格,您可能会选择创建一个继承公共接口的简单类。我们将在本章稍后部分介绍这一点。

Note If you are working in a more object-oriented style, you might choose to create a simple class that inherits from a common interface. We’ll cover that a bit later in the chapter.

FakeTimeProvider接下来,我们将在测试中构建并将其注入到verifier测试中:

Next, we’ll construct the FakeTimeProvider in our tests and inject it into the verifier under test:

描述('验证者', () => {
    test('周末,抛出异常', () => {
        常量验证器 =
             新的PasswordVerifier([],新的FakeTimeProvider(SUNDAY) );
 
        Expect(()=> verifier.verify('任何东西'))
            .toThrow("周末到了!");
    });
describe('verifier', () => {
    test('on weekends, throws exception', () => {
        const verifier = 
             new PasswordVerifier([], new FakeTimeProvider(SUNDAY));
 
        expect(()=> verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });

完整的测试文件如下所示。

Here’s what the full test file looks like.

清单 3.13 创建手写桩对象

Listing 3.13 Creating a handwritten stub object

函数FakeTimeProvider (fakeDay) {
    this.getDay = 函数 () {
        返回假日;
    }
}
 
描述('验证者', () => {
    test('类构造函数:周末,抛出异常', () => {
        常量验证器 =
            新的PasswordVerifier([],新的FakeTimeProvider(SUNDAY) );
 
        Expect(() => verifier.verify('任何东西'))
            .toThrow("周末到了!");
    });
});
function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}
 
describe('verifier', () => {
    test('class constructor: on weekends, throws exception', () => {
        const verifier = 
            new PasswordVerifier([], new FakeTimeProvider(SUNDAY));
 
        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
}); 

该代码之所以有效,是因为默认情况下 JavaScript 是一种非常宽松的语言。就像 Ruby 或 Python 一样,您可以避免使用鸭子类型。鸭子类型是指如果它像鸭子一样走路并且像鸭子一样说话,我们就会像鸭子一样对待它。在这种情况下,真实对象和假对象都实现相同的功能,即使它们是完全不同的对象。我们可以简单地发送一个来代替另一个,并且生产代码应该可以接受。

This code works because JavaScript, by default, is a very permissive language. Much like Ruby or Python, you can get away with duck typing things. Duck typing refers to the idea that if it walks like a duck and it talks like a duck, we’ll treat it like a duck. In this case, the real object and fake object both implement the same function, even though they are completely different objects. We can simply send one in place of the other, and the production code should be OK with this.

当然,我们只会知道这没问题,并且我们在运行时没有犯任何错误或遗漏有关函数签名的任何内容。如果我们想要更有信心,我们可以以更类型安全的方式尝试。

Of course, we’ll only know that this is OK and that we didn’t make any mistakes or miss anything regarding the function signatures at run time. If we want a bit more confidence, we can try it in a more type-safe manner.

3.7.3 提取公共接口

3.7.3 Extracting a common interface

我们可以一步到位进一步,以及,如果我们使用 TypeScript 或强类型语言(例如 Java 或 C#),请开始使用接口来表示依赖项所扮演的角色。我们可以创建某种契约,真实对象和假对象都必须在编译器级别遵守。

We can take things one step further, and, if we’re using TypeScript or a strongly typed language such as Java or C#, start using interfaces to denote the roles that our dependencies play. We can create a contract of sorts that both real objects and fake objects will have to abide by at the compiler level.

首先,我们将定义新接口(请注意,这现在是 TypeScript 代码):

First, we’ll define our new interface (notice that this is now TypeScript code):

导出接口 TimeProviderInterface {
    getDay():数字;
}
export interface TimeProviderInterface {
    getDay(): number;
}

其次,我们将定义一个实时提供程序,在我们的生产代码中实现我们的接口,如下所示:

Second, we’ll define a real time provider that implements our interface in our production code like this:

从“时刻”导入*作为时刻;
从“./time-provider-interface”导入{TimeProviderInterface};
 
导出类 RealTimeProvider实现 TimeProviderInterface {
    getDay(): 数字 {
        返回时刻().day();
    }
}
import * as moment from "moment";
import {TimeProviderInterface} from "./time-provider-interface";
 
export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}

第三,我们将更新 our 的构造函数PasswordVerifier以获取新类型的依赖项TimeProviderInterface,而不是使用 的参数类型RealTimeProvider。我们抽象了时间提供者的角色,并声明我们不关心传递的对象是什么,只要它响应该角色的接口即可:

Third, we’ll update the constructor of our PasswordVerifier to take a dependency of our new TimeProviderInterface type, instead of having a parameter type of RealTimeProvider. We’re abstracting away the role of a time provider and declaring that we don’t care what object is being passed, as long as it answers to this role’s interface:

导出类PasswordVerifier {
     private _ timeProvider: TimeProviderInterface;
 
    构造函数(规则:any[],timeProvider:TimeProviderInterface){
        this._timeProvider = timeProvider;
    }
 
    验证(输入:字符串):字符串[] {
        const isWeekened = [周日、周六]
            .filter(x => x === this._ timeProvider.getDay () )
            .长度> 0;
        if (isWeekened) {
            抛出新的错误(“这是周末!”)
        }
         // 这里有更多逻辑
        返回 [];
    }
}
export class PasswordVerifier {
    private _timeProvider: TimeProviderInterface;
 
    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }
 
    verify(input: string):string[] {
        const isWeekened = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length > 0;
        if (isWeekened) {
            throw new Error("It's the weekend!")
        }
         // more logic goes here
        return [];
    }
}

现在我们有了一个定义“鸭子”外观的接口,我们可以在测试中实现我们自己的鸭子。它看起来很像之前测试的代码,但有一个很大的区别:它将经过编译器检查以确保方法签名的正确性。

Now that we have an interface that defines what a “duck” looks like, we can implement a duck of our own in our tests. It’s going to look a lot like the previous test’s code, but it will have one strong difference: it will be compiler checked to ensure the correctness of the method signatures.

这是我们的假时间提供程序在测试文件中的样子:

Here’s what our fake time provider looks like in our test file:

类 FakeTimeProvider实现 TimeProviderInterface {
    fakeDay:数字;
    getDay(): 数字 {
        返回 this.fakeDay;
    }
}
class FakeTimeProvider implements TimeProviderInterface {
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}

这是我们的测试:

And here’s our test:

描述('带有接口的密码验证器',()=> {
    test('周末,抛出异常', () => {
        const StubTimeProvider = new FakeTimeProvider();
        StubTimeProvider.fakeDay = 周日;
        const verifier = new PasswordVerifier( [] , stubTimeProvider) ;
 
        Expect(() => verifier.verify('任何东西'))
            .toThrow("周末到了!");
    });
});
describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);
 
        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

以下清单显示了所有代码。

The following listing shows all the code together.

清单 3.14 在生产代码中提取通用接口

Listing 3.14 Extracting a common interface in production code

导出接口 TimeProviderInterface { getDay(): number; }
  
导出类 RealTimeProvider 实现 TimeProviderInterface {
    getDay(): 数字 {
        返回时刻().day();
    }
}
 
导出类密码验证器{
    私有_timeProvider:TimeProvider接口;
 
    构造函数(规则:any[],timeProvider:TimeProviderInterface){
        this._timeProvider = timeProvider;
    }
    验证(输入:字符串):字符串[] {
        const isWeekend = [周日、周六]
            .filter(x => x === this._timeProvider.getDay())
            .长度>0;
        如果(是周末){
            抛出新的错误(“这是周末!”)
        }
        返回 [];
    }
}
 
类 FakeTimeProvider 实现 TimeProviderInterface{
    fakeDay:数字;
    getDay(): 数字 {
        返回 this.fakeDay;
    }
}
 
描述('带有接口的密码验证器',()=> {
    test('周末,抛出异常', () => {
        const StubTimeProvider = new FakeTimeProvider();
        StubTimeProvider.fakeDay = 周日;
        constverifier = newPasswordVerifier([],stubTimeProvider);
 
        Expect(() => verifier.verify('任何东西'))
            .toThrow("周末到了!");
    });
});
export interface TimeProviderInterface {  getDay(): number;  }
  
export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}
 
export class PasswordVerifier {
    private _timeProvider: TimeProviderInterface;
 
    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }
    verify(input: string):string[] {
        const isWeekend = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length>0;
        if (isWeekend) {
            throw new Error("It's the weekend!")
        }
        return [];
    }
}
 
class FakeTimeProvider implements TimeProviderInterface{
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}
 
describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);
 
        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

我们现在已经从纯粹的函数式设计完全转变为强类型、面向对象的设计。哪一个最适合您的团队和您的项目?没有单一的答案。我将在第 8 章中详细讨论设计。在这里,我主要想表明,无论您最终选择哪种设计,注入模式基本保持不变。它只是通过不同的词汇或语言特征来启用。

We’ve now made a full transition from a purely functional design into a strongly typed, object-oriented design. Which is best for your team and your project? There’s no single answer. I’ll talk more about design in chapter 8. Here, I mainly wanted to show that whatever design you end up choosing, the pattern of injection remains largely the same. It is just enabled with different vocabulary or language features.

注入的能力使我们能够模拟在现实生活中几乎不可能测试的事情。这就是桩的想法最闪耀的地方。我们可以告诉我们的桩返回假值,甚至模拟代码中的异常,以查看它如何处理由依赖项引起的错误。注射使这成为可能。注入还使我们的测试更具可重复性、一致性和可信性,我将在本书的第三部分讨论可信性。在下一章中,我们将研究模拟对象并了解它们与桩有何不同。

It’s the ability to inject that enables us to simulate things that would be practically impossible to test in real life. That’s where the idea of stubs shines the most. We can tell our stubs to return fake values or even to simulate exceptions in our code, to see how it handles errors arising from dependencies. Injection makes this possible. Injection has also made our tests more repeatable, consistent, and trustworthy, and I’ll talk about trustworthiness in the third part of this book. In the next chapter, we’ll look at mock objects and see how they differ from stubs.

概括

Summary

  • 测试替身是一个总体术语,描述测试中各种非生产就绪的假依赖项。测试替身有五种变体,可以分为两种类型:模拟

  • Test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in tests. There are five variations on test doubles that can be grouped into just two types: mocks and stubs.

  • 模拟有助于模拟和检查传出依赖项:代表我们工作单元的退出点的依赖项。被测系统 (SUT) 调用传出依赖项来更改这些依赖项的状态。有助于模拟传入的依赖关系:SUT 调用此类依赖关系来获取输入数据。

  • Mocks help emulate and examine outgoing dependencies: dependencies that represent an exit point of our unit of work. The system under test (SUT) calls outgoing dependencies to change the state of those dependencies. Stubs help emulate incoming dependencies: the SUT makes calls to such dependencies to get input data.

  • 桩有助于用虚假的、可靠的依赖项替换不可靠的依赖项,从而避免测试不稳定

  • Stubs help replace an unreliable dependency with a fake, reliable one and thus avoid test flakiness.

  • 有多种方法可以将桩注入到工作单元中:

    • 函数作为参数——注入函数而不是普通值。

    • 部分应用程序(柯里化)和工厂函数- 创建一个函数,该函数返回另一个包含一些上下文的函数。此上下文可能包括您用桩替换的依赖项。

    • 模块注入——用具有相同 API 的假模块替换模块。这种方法是脆弱的。如果您伪造的模块将来更改其 API,您可能需要进行大量重构。

    • 构造函数——这与部分应用程序基本相同。

    • 类构造函数注入——这是一种常见的面向对象技术,您可以通过构造函数注入依赖项。

    • 对象作为参数(又名鸭子类型) ——在 JavaScript 中,您可以注入任何依赖项来代替所需的依赖项,只要该依赖项实现相同的功能即可。

    • 公共接口作为参数——这与对象作为参数相同,但它涉及编译时的检查。对于这种方法,您需要一种强类型语言,例如 TypeScript。

  • There are multiple ways to inject a stub into a unit of work:

    • Function as parameter—Injecting a function instead of a plain value.

    • Partial application (currying) and factory functions—Creating a function that returns another function with some of the context baked in. This context may include the dependency you replaced with a stub.

    • Module injection—Replacing a module with a fake one with the same API. This approach is fragile. You may need a lot of refactoring if the module you are faking changes its API in the future.

    • Constructor function—This is mostly the same as partial application.

    • Class constructor injection—This is a common object-oriented technique where you inject a dependency via a constructor.

    • Object as parameter (aka duck typing)—In JavaScript, you can inject any dependency in place of the required one as long as that dependency implements the same functions.

    • Common interface as parameter—This is the same as object as parameter, but it involves a check during compile time. For this approach, you need a strongly typed language like TypeScript.

4 使用模拟对象进行交互测试

4 Interaction testing using mock objects

本章涵盖

This chapter covers

  • 定义交互测试
  • Defining interaction testing
  • 使用模拟对象的原因
  • Reasons to use mock objects
  • 注入和使用模拟
  • Injecting and using mocks
  • 处理复杂的接口
  • Dealing with complicated interfaces
  • 部分模拟
  • Partial mocks

在上一章中,我们解决了测试依赖于其他对象的代码能否正确运行的问题。我们使用桩来确保被测代码收到所需的所有输入,以便我们可以单独测试工作单元。

In the previous chapter, we solved the problem of testing code that depends on other objects to run correctly. We used stubs to make sure that the code under test received all the inputs it needed so that we could test the unit of work in isolation.

到目前为止,您只编写了针对工作单元可以具有的三种退出点类型中的前两种的测试:返回值更改系统状态(您可以在第 1 章中阅读有关这些类型的更多信息) )。在本章中,我们将了解如何测试第三种类型的退出点——对第三方函数、模块或对象的调用。这很重要,因为我们的代码通常取决于我们无法控制的事情。知道如何检查该类型的代码是单元测试领域的一项重要技能。基本上,我们将找到方法来证明我们的工作单元最终会调用我们无法控制的函数,并确定哪些值作为参数发送。

So far, you’ve only written tests that work against the first two of the three types of exit points a unit of work can have: returning a value and changing the state of the system (you can read more about these types in chapter 1). In this chapter, we’ll look at how you can test the third type of exit point—a call to a third-party function, module, or object. This is important, because often we’ll have code that depends on things we can’t control. Knowing how to check that type of code is an important skill in the world of unit testing. Basically, we’ll find ways to prove that our unit of work ends up calling a function that we don’t control and identify what values were sent as arguments.

到目前为止我们看到的方法在这里不起作用,因为第三方函数通常没有专门的 API 来允许我们检查它们是否被正确调用。相反,他们将其操作内部化以提高清晰度和可维护性。那么,如何测试您的工作单元是否与第三方函数正确交互?你使用模拟。

The approaches we’ve looked at so far won’t do here, because third-party functions usually don’t have specialized APIs that allow us to check if they were called correctly. Instead, they internalize their operations for clarity and maintainability. So, how can you test that your unit of work interacts with third-party functions correctly? You use mocks.

4.1 交互测试、模拟和桩

4.1 Interaction testing, mocks, and stubs

交互测试正在检查工作单元如何与超出其控制的依赖项交互并向其发送消息(即调用函数)。模拟函数或对象用于断言已正确调用外部依赖项。

Interaction testing is checking how a unit of work interacts with and sends messages (i.e., calls functions) to a dependency beyond its control. Mock functions or objects are used to assert that a call was made correctly to an external dependency.

让我们回顾一下模拟和桩之间的区别,正如我们在第 3 章中介绍的那样。主要区别在于信息流:

Let’s recall the differences between mocks and stubs as we covered them in chapter 3. The main difference is in the flow of information:

  • 模拟——用于打破传出依赖关系。模拟是我们断言在测试中调用的假模块、对象或函数。模拟代表单元测试中的退出点。如果我们不对它进行断言,它就不会被用作模拟。

    出于可维护性和可读性的原因,每个测试不超过一个模拟是正常的。(我们将在本书有关编写可维护测试的第 3 部分中详细讨论这一点。)

  • Mock—Used to break outgoing dependencies. Mocks are fake modules, objects, or functions that we assert were called in our tests. A mock represents an exit point in a unit test. If we don’t assert on it, it’s not used as a mock.

    It is normal to have no more than a single mock per test, for maintainability and readability reasons. (We’ll discuss this more in part 3 of this book about writing maintainable tests.)

  • ——用于破坏传入的依赖关系。桩是假模块、对象或函数,它们向被测代码提供假行为或数据。我们不会对它们进行断言,并且我们可以在一次测试中拥有许多桩。

    桩代表路径点,而不是退出点,因为数据或行为流入工作单元。它们是交互点,但并不代表工作单元的最终结果。相反,它们是实现我们关心的最终结果的过程中的交互,因此我们不会将它们视为退出点。

  • Stub—Used to break incoming dependencies. Stubs are fake modules, objects, or functions that provide fake behavior or data to the code under test. We do not assert against them, and we can have many stubs in a single test.

    Stubs represent waypoints, not exit points, because the data or behavior flows into the unit of work. They are points of interaction, but they do not represent an ultimate outcome of the unit of work. Instead, they are an interaction on the way to achieving the end result we care about, so we don’t treat them as exit points.

图 4.1 并排显示了这两者。

Figure 4.1 shows these two side by side.

04-01



图 4.1 左侧是通过调用依赖项实现的退出点。右侧,依赖项提供间接输入或行为,而不是退出点。

Figure 4.1 On the left, an exit point that is implemented as invoking a dependency. On the right, the dependency provides indirect input or behavior and is not an exit point.

让我们看一个我们无法控制的依赖项的退出点的简单示例:调用记录器。

Let’s look at a simple example of an exit point to a dependency that we do not control: calling a logger.

4.2 取决于记录器

4.2 Depending on a logger

让我们以这个密码验证器函数作为我们的起始示例,我们假设我们有一个复杂的记录器(这是一个具有更多函数和参数的记录器,因此界面可能会带来更多挑战)。我们函数的要求之一是在验证通过或失败时调用记录器,如下所示。

Let’s take this Password Verifier function as our starting example, and we’ll assume we have a complicated logger (which is a logger that has more functions and parameters, so the interface may present more of a challenge). One of the requirements of our function is to call the logger when verification has passed or failed, as follows.

清单 4.1 直接依赖于复杂的记录器

Listing 4.1 Depending directly on a complicated logger

// 使用传统的注入技术不可能伪造
const log = require('./complicated-logger');
 
const verifyPassword = (输入, 规则) => {
  const 失败 = 规则
    .map(规则=>规则(输入))
    .filter(结果 => 结果 === false);
  if (失败.count === 0) {
    // 使用传统的注入技术进行测试
    log.info('通过');                                       
    返回true;//                                           
  }
  //无法用传统的注入技术进行测试
  log.info('失败'); //                                        
  返回 false; //                                            
};
 
const info = (text) => { 
    console.log(`INFO: ${text}`); 
};
常量调试=(文本)=> {
    console.log(`调试:${text}`);
};
// impossible to fake with traditional injection techniques
const log = require('./complicated-logger');
 
const verifyPassword = (input, rules) => {
  const failed = rules
    .map(rule => rule(input))
    .filter(result => result === false);
  if (failed.count === 0) {
    // to test with traditional injection techniques
    log.info('PASSED');                                      
    return true; //                                          
  }
  //impossible to test with traditional injection techniques
  log.info('FAIL'); //                                       
  return false; //                                           
};
 
const info = (text) => {
    console.log(`INFO: ${text}`);
};
const debug = (text) => {
    console.log(`DEBUG: ${text}`);
};

出口处

Exit point

图 4.2 说明了这一点。我们的verifyPassword函数是工作单元的入口点,总共有两个出口点:一个返回值,另一个调用log.info().

Figure 4.2 illustrates this. Our verifyPassword function is the entry point to the unit of work, and we have a total of two exit points: one that returns a value, and another that calls log.info().

04-02



图 4.2 密码验证器的入口点是函数verifyPassword。一个退出点返回一个值,另一个退出点调用log.info().

Figure 4.2 The entry point to the Password Verifier is the verifyPassword function. One exit point returns a value, and the other calls log.info().

不幸的是,我们无法logger通过使用任何传统方式来验证它的调用,或者不使用一些 Jest 技巧,我通常仅在没有其他选择的情况下使用这些技巧,因为它们往往会降低测试的可读性并且更难以维护(稍后会详细介绍)本章)。

Unfortunately, we cannot verify that logger was called by using any traditional means, or without using some Jest tricks, which I usually use only if there’s no other choice, as they tend to make tests less readable and harder to maintain (more on that later in this chapter).

让我们对依赖项做我们喜欢做的事情:抽象它们。有很多方法可以在我们的代码中创建接缝。记住,接缝是两段代码相遇的地方——我们可以用它们来注入假东西。表 4.1 列出了抽象依赖关系的最常见方法。

Let’s do what we like to do with dependencies: abstract them. There are many ways to create a seam in our code. Remember, seams are places where two pieces of code meet—we can use them to inject fake things. Table 4.1 lists the most common ways to abstract dependencies.

表 4.1 注入假货的技术

Table 4.1 Techniques for injecting fakes

风格

Style

技术

Technique

标准

Standard

介绍参数

Introduce parameter

功能性

Functional

使用 currying 转换为高阶函数

Use curryingConvert to higher-order functions

模块化的

Modular

抽象模块依赖

Abstract module dependency

面向对象

Object oriented

注入无类型对象Inject 接口

Inject untyped objectInject interface

4.3 标准风格:引入参数重构

4.3 Standard style: Introduce parameter refactoring

我们开始这一旅程的最明显的方法是在我们的测试代码中引入一个新参数。

The most obvious way we can start this journey is by introducing a new parameter into our code under test.

清单 4.2 模拟记录器参数注入

Listing 4.2 Mock logger parameter injection

const verifyPassword2 = (输入, 规则,记录器) => {
    const 失败 = 规则
        .map(规则=>规则(输入))
        .filter(结果 => 结果 === false);
 
    if (失败.length === 0) {
        logger.info('通过');
        返回真;
    }
    logger.info('失败');
    返回假;
};
const verifyPassword2 = (input, rules, logger) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);
 
    if (failed.length === 0) {
        logger.info('PASSED');
        return true;
    }
    logger.info('FAIL');
    return false;
};

下面的清单显示了我们如何使用简单的闭包机制为此编写最简单的测试。

The following listing shows how we could write the simplest of tests for this, using a simple closure mechanism.

清单 4.3 手写模拟对象

Listing 4.3 Handwritten mock object

描述('带有记录器的密码验证器', () => {
    描述('当所有规则都通过时', () => {
        it('通过 PASSED 调用记录器', () => { 
            let write = ''; 
           const mockLog = { 
                info: (text) => { 
                    write = text; 
                } 
           };
 
            verifyPassword2('任何内容', [], mockLog );
 
            期望().toMatch(/通过/);
        });
    });
});
describe('password verifier with logger', () => {
    describe('when all rules pass', () => {
        it('calls the logger with PASSED', () => {
            let written = '';
            const mockLog = {
                info: (text) => {
                    written = text;
                }
            };
 
            verifyPassword2('anything', [], mockLog);
 
            expect(written).toMatch(/PASSED/);
        });
    });
});

首先请注意,我们命名变量mockXXXmockLog在本例中)是为了表示我们在测试中有一个模拟函数或对象。我使用这种命名约定是因为我希望您作为测试的读者知道您应该在测试结束时针对该模拟进行断言(也称为验证)。这种命名方法消除了读者的意外因素,并使测试更加可预测。仅对实际模拟的事物使用此命名约定。

Notice first that we are naming the variable mockXXX (mockLog in this example) to denote the fact that we have a mock function or object in the test. I use this naming convention because I want you, as a reader of the test, to know that you should expect an assert (also known as verification) against that mock at the end of the test. This naming approach removes the element of surprise for the reader and makes the test much more predictable. Only use this naming convention for things that are actually mocks.

这是我们的第一个模拟对象:

Here’s our first mock object:

让书面='';
常量模拟日志 = {
    信息:(文本)=> {
        书面=文字;
    }
};
let written = '';
const mockLog = {
    info: (text) => {
        written = text;
    }
};

它只有一个功能,模仿记录器功能的签名info。然后它会保存传递给它的参数 ( text),以便我们可以断言它在稍后的测试中被调用。如果written变量具有正确的文本,则证明我们的函数被调用,这意味着我们已经证明我们的工作单元正确调用了退出点。

It only has one function, which mimics the signature of the logger’s info function. It then saves the parameter being passed to it (text) so that we can assert that it was called later in the test. If the written variable has the correct text, this proves that our function was called, which means we have proven that the exit point is invoked correctly from our unit of work.

另一方面verifyPassword2,我们所做的重构很常见。这与我们在上一章中所做的几乎相同,我们提取了一个作为依赖项。在重构和在应用程序代码中引入接缝方面,桩和模拟通常以相同的方式处理。

On the verifyPassword2 side, the refactoring we did is pretty common. It’s pretty much the same as we did in the previous chapter, where we extracted a stub as a dependency. Stubs and mocks are often treated the same way in terms of refactoring and introducing seams in our application’s code.

这种简单的参数重构为我们提供了什么?

What did this simple refactoring into a parameter provide us with?

  • 我们不再需要在测试的代码中显式导入(通过require) 。logger这意味着,如果我们更改记录器的真正依赖项,则被测试的代码将少一个需要更改的理由。

  • We do not need to explicitly import (via require) the logger in our code under test anymore. That means that if we ever change the real dependency of the logger, the code under test will have one less reason to change.

  • 现在,我们可以将我们选择的任何记录器注入到被测代码中,只要它符合相同的接口(或至少具有该info方法)。这意味着我们可以提供一个模拟记录器来为我们执行命令:模拟记录器帮助我们验证它是否被正确调用。

  • We now have the ability to inject any logger of our choosing into the code under test, as long as it lives up to the same interface (or at least has the info method). This means that we can provide a mock logger that does our bidding for us: the mock logger helps us verify that it was called correctly.

注意我们的模拟对象仅模仿 的接口的一部分logger(它缺少函数debug)这一事实是鸭子类型的一种形式。我在第三章讨论了这个想法:如果它像鸭子一样走路,并且像鸭子一样说话,那么我们可以将它用作假对象。

Note The fact that our mock object only mimics a part of the logger’s interface (it’s missing the debug function) is a form of duck typing. I discussed this idea in chapter 3: if it walks like a duck, and it talks like a duck, then we can use it as a fake object.

4.4 区分模拟和桩的重要性

4.4 The importance of differentiating between mocks and stubs

为什么我如此关心我们对每件事的命名?如果我们无法区分模拟和桩之间的区别,或者我们没有正确命名它们,那么我们最终可能会得到测试多个事物的测试,并且这些测试的可读性较差且难以维护。正确命名事物可以帮助我们避免这些陷阱。

Why do I care so much about what we name each thing? If we can’t tell the difference between mocks and stubs, or we don’t name them correctly, we can end up with tests that are testing multiple things and that are less readable and harder to maintain. Naming things correctly helps us avoid these pitfalls.

鉴于模拟代表了我们工作单元的要求(“它调用记录器”,“它发送电子邮件”等),并且桩代表传入的信息或行为(“数据库查询返回 false”,“这个特定配置会引发错误”),我们可以设置一个简单的经验法则:在测试中拥有多个桩应该没问题,但您通常不希望每个测试有多个模拟,因为这意味着您在一次测试中测试了多个需求。

Given that a mock represents a requirement from our unit of work (“it calls the logger,” “it sends an email,” etc.) and that a stub represents incoming information or behavior (“the database query returns false,” “this specific configuration throws an error”), we can set a simple rule of thumb: It should be OK to have multiple stubs in a test, but you don’t usually want to have more than a single mock per test, because that would mean you’re testing more than one requirement in a single test.

如果我们不能(或不会)区分事物(命名是关键),我们最终可能会在每个测试中进行多个模拟或断言我们的桩,这两者都会对我们的测试产生负面影响。保持命名一致给我们带来以下好处:

If we can’t (or won’t) differentiate between things (naming is key to that), we can end up with multiple mocks per test or asserting our stubs, both of which can have negative effects on our tests. Keeping naming consistent gives us the following benefits:

  • 可读性——您的测试名称将变得更加通用且难以理解。您希望人们能够阅读测试的名称并了解其中发生或测试的所有内容,而无需阅读测试的代码。

  • Readability—Your test name will become much more generic and harder to understand. You want people to be able to read the name of the test and know everything that happens or is tested inside of it, without needing to read the test’s code.

  • 可维护性——如果您不区分模拟和桩,您可以在没有注意到甚至不关心的情况下对桩进行断言。这对您产生的价值很小,并且增加了测试和内部生产代码之间的耦合。断言您查询数据库就是一个很好的例子。与其测试数据库查询是否返回某个值,不如测试在更改数据库输入后应用程序的行为是否发生变化。

  • Maintainability—You could, without noticing or even caring, assert against stubs if you don’t differentiate between mocks and stubs. This produces little value to you and increases the coupling between your tests and internal production code. Asserting that you queried a database is a good example of this. Instead of testing that a database query returns some value, it would be much better to test that the application’s behavior changes after we change the input from the database.

  • 信任- 如果在单个测试中有多个模拟(要求),并且第一个模拟验证使测试失败,则大多数测试框架将不会执行测试的其余部分(在失败的断言行下方),因为已引发异常。这意味着其他模拟未经过验证,您将无法从中获得结果。

  • Trust—If you have multiple mocks (requirements) in a single test, and the first mock verification fails the test, most test frameworks won’t execute the rest of the test (below the failing assert line) because an exception has been thrown. This means that the other mocks aren’t verified, and you won’t get the results from them.

为了强调最后一点,想象一下一位医生只看到了病人 30% 的症状,但仍然需要做出决定——他们可能会做出错误的治疗决定。如果您看不到所有错误在哪里,或者有两件事失败而不是只有一个(因为其中一个在第一次失败后被隐藏),那么您更有可能修复错误的问题或修复它错误的地方。

To drive the last point home, imagine a doctor who only sees 30% of their patient’s symptoms, but still needs to make a decision—they might make the wrong decision about treatment. If you can’t see where all the bugs are, or that two things are failing instead of just one (because one of them is hidden after the first failure), you’re more likely to fix the wrong thing or to fix it in the wrong place.

Gerard MeszarosXUnit 测试模式Addison-Wesley,2007)将这种情况称为断言轮盘赌http://xunitpatterns.com/Assertion%20Roulette.xhtml)。我喜欢这个名字。这真是一场赌博。你开始注释掉测试中的代码行,随之而来的是很多乐趣(可能还包括酒精)。

XUnit Test Patterns (Addison-Wesley, 2007), by Gerard Meszaros, calls this situation assertion roulette (http://xunitpatterns.com/Assertion%20Roulette.xhtml). I like this name. It’s quite a gamble. You start commenting out lines of code in your test, and lots of fun ensues (and possibly alcohol).

并非一切都是模拟

Not everything is a mock

不幸的是,人们仍然倾向于使用“模拟”一词来表示任何不真实的事物,例如“模拟数据库”或“模拟服务”。大多数时候,他们真正的意思是他们正在使用桩。

It’s unfortunate that people still tend to use the word “mock” for anything that isn’t real, such as “mock database” or “mock service.” Most of the time they really mean they are using a stub.

不过,很难责怪他们。像 Mockito、jMock 这样的框架和大多数隔离框架(我不称它们为模拟框架,出于与我现在讨论的相同的原因),使用“模拟”一词来表示模拟和桩。

It’s hard to blame them, though. Frameworks like Mockito, jMock, and most isolation frameworks (I don’t call them mocking frameworks, for the same reasons I’m discussing right now), use the word “mock” to denote both mocks and stubs.

有一些较新的框架,例如 JavaScript 中的 Sinon 和 testdouble、.NET 中的 NSubstitute 和 FakeItEasy 等,它们帮助开始了命名约定的变化。我希望这种情况能够持续下去。

There are newer frameworks, such as Sinon and testdouble in JavaScript, NSubstitute and FakeItEasy in .NET, and others, that have helped start a change in the naming conventions. I hope this persists.

4.5 模块化风格的模拟

4.5 Modular-style mocks

我在前一章中介绍了模块化依赖注入,但现在我们将了解如何使用它来注入模拟对象并模拟它们的答案。

I covered modular dependency injection in the previous chapter, but now we’re going to look at how we can use it to inject mock objects and simulate answers on them.

4.5.1 生产代码示例

4.5.1 Example of production code

让我们看一个比之前看到的稍微复杂一些的例子。在这种情况下,我们的verifyPassword函数依赖于两个外部依赖项:

Let’s look at a slightly more complicated example than we saw before. In this scenario, our verifyPassword function depends on two external dependencies:

  • 记录器

  • A logger

  • 配置服务

  • A configuration service

配置服务提供所需的日志记录级别。通常这种类型的代码会被移动到一个特殊的记录器模块中,但出于本书示例的目的,我将调用logger.info和 的逻辑logger.debug直接放在测试的代码中。

The configuration service provides the logging level that is required. Usually this type of code would be moved into a special logger module, but for the purposes of this book’s examples, I’m putting the logic that calls logger.info and logger.debug directly in the code under test.

清单 4.4 硬模块依赖

Listing 4.4 A hard modular dependency

const { info, debug } = require("./complicated-logger"); 
const { getLogLevel } = require("./configuration-service");
 
常量日志=(文本)=> {
  if ( getLogLevel() === "信息") {
    信息(文本);
  }
  if ( getLogLevel() === "调试") {
    调试(文本);
  }
};
 
const verifyPassword = (输入, 规则) => {
  const 失败 = 规则
    .map((规则) => 规则(输入))
    .filter((结果) => 结果 === false);
 
  if (失败.length === 0) {
    日志(“通过”);   
    返回真;
  }
  日志(“失败”);       
  返回假;
};
 
模块. 导出 = {
  验证密码,
};
const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
 
const log = (text) => {
  if (getLogLevel() === "info") {
    info(text);
  }
  if (getLogLevel() === "debug") {
    debug(text);
  }
};
 
const verifyPassword = (input, rules) => {
  const failed = rules
    .map((rule) => rule(input))
    .filter((result) => result === false);
 
  if (failed.length === 0) {
    log("PASSED");   
    return true;
  }
  log("FAIL");       
  return false;
};
 
module.exports = {
  verifyPassword,
};

调用记录器

Calling the logger

假设我们在调用记录器时意识到有一个错误。我们改变了检查失败的方式,现在PASSED当失败次数为正而不是零时,我们会调用记录器并返回结果。我们如何通过单元测试证明这个错误存在,或者我们已经修复了它?

Let’s assume that we realized we have a bug when we call the logger. We’ve changed the way we check for failures, and now we call the logger with a PASSED result when the number of failures is positive instead of zero. How can we prove that this bug exists, or that we’ve fixed it, with a unit test?

我们的问题是我们直接在代码中导入(或要求)模块。如果我们想要替换记录器模块,我们必须替换文件或通过 Jest 的 API 执行一些其他黑魔法。我通常不建议这样做,因为在处理代码时使用这些技术会导致比平常更多的痛苦和痛苦。

Our problem here is that we are importing (or requiring) the modules directly in our code. If we want to replace the logger module, we have to either replace the file or perform some other dark magic through Jest’s API. I wouldn’t recommend that usually, because using these techniques leads to more pain and suffering than is usual when dealing with code.

4.5.2 以模块化注入方式重构生产代码

4.5.2 Refactoring the production code in a modular injection style

我们可以将模块依赖项抽象为它们自己的对象,并允许模块的用户按如下方式替换该对象。

We can abstract away the module dependencies into their own object and allow the user of our module to replace that object as follows.

清单 4.5 重构为模块化注入模式

Listing 4.5 Refactoring to a modular injection pattern

const originDependency = {                      
    log: require('./complicated-logger'),           
};                                                 
 
让依赖项 = { ...originalDependency };    
 
const ResetDependency = () => {                   
    dependency = { ...originalDependencies };    
};                                                 
 
constjectDependency = (fakes) => {             
    Object.assign(dependency, fakes);            
};                                                 
 
const verifyPassword = (输入, 规则) => {
    const 失败 = 规则
        .map(规则=>规则(输入))
        .filter(结果 => 结果 === false);
 
    if (失败.length === 0) {
        dependency.log.info('通过');
        返回真;
    }
    dependency.log.info('失败');
    返回假;
};
 
模块. 导出 = {
    验证密码,                                 
    注入依赖项,                             
    重置依赖项                               
};
const originalDependencies = {                     
    log: require('./complicated-logger'),          
};                                                 
 
let dependencies = { ...originalDependencies };    
 
const resetDependencies = () => {                  
    dependencies = { ...originalDependencies };    
};                                                 
 
const injectDependencies = (fakes) => {            
    Object.assign(dependencies, fakes);            
};                                                 
 
const verifyPassword = (input, rules) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);
 
    if (failed.length === 0) {
        dependencies.log.info('PASSED');
        return true;
    }
    dependencies.log.info('FAIL');
    return false;
};
 
module.exports = {
    verifyPassword,                                
    injectDependencies,                            
    resetDependencies                              
};

保持原始依赖关系

Holding original dependencies

间接层

The layer of indirection

重置依赖关系的函数

A function that resets the dependencies

覆盖依赖项的函数

A function that overrides the dependencies

向模块的用户公开 API

Exposing the API to the users of the module

这里有更多的生产代码,而且看起来更复杂,但是如果我们被迫以这种模块化的方式工作,这允许我们以相对简单的方式替换测试中的依赖项。

There’s more production code here, and it seems more complex, but this allows us to replace dependencies in our tests in a relatively easy manner if we are forced to work in such a modular fashion.

originalDependencies变量将始终保留原始依赖关系,因此我们永远不会在测试之间丢失它们。dependencies是我们的间接层。它默认为原始依赖项,但我们的测试可以指示被测代码用自定义依赖项替换该变量(无需了解有关模块内部的任何信息)。injectDependenciesresetDependencies是模块公开的用于覆盖和重置依赖项的公共 API。

The originalDependencies variable will always hold the original dependencies, so that we never lose them between tests. dependencies is our layer of indirection. It defaults to the original dependencies, but our tests can direct the code under test to replace that variable with custom dependencies (without knowing anything about the internals of the module). injectDependencies and resetDependencies are the public API that the module exposes for overriding and resetting the dependencies.

4.5.3 模块化注入的测试示例

4.5.3 A test example with modular-style injection

以下清单显示了模块化注入测试的样子。

The following listing shows what a test for modular injection might look like.

清单 4.6 使用模块化注入进行测试

Listing 4.6 Testing with modular injection

常量{
  验证密码,
  注入依赖关系,
  重置依赖关系,
} = require("./密码验证器-可注入");
 
描述(“密码验证器”,()=> {
  afterEach(重置依赖关系);
 
  描述(“给定的记录器和传递场景”,()=> {
    it("使用 PASS 调用记录器", () => {
      让记录=“”;
      const mockLog = { 信息: (文本) => (记录 = 文本) };
      注入依赖项({日志:mockLog});
 
      验证密码(“任何内容”,[]);
 
      期望(记录)。toMatch(/通过/);
    });
  });
});
const {
  verifyPassword,
  injectDependencies,
  resetDependencies,
} = require("./password-verifier-injectable");
 
describe("password verifier", () => {
  afterEach(resetDependencies);
 
  describe("given logger and passing scenario", () => {
    it("calls the logger with PASS", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      injectDependencies({ log: mockLog });
 
      verifyPassword("anything", []);
 
      expect(logged).toMatch(/PASSED/);
    });
  });
});

只要我们不忘记resetDependencies在每次测试后使用该函数,我们现在就可以很容易地注入模块以进行测试。明显的主要警告是,这种方法要求每个模块公开可以从外部使用的注入和重置函数。这可能适用于您当前的设计限制,也可能不适用于您当前的设计限制,但如果适用,您可以将它们抽象为可重用的函数,并为自己节省大量样板代码。

As long as we don’t forget to use the resetDependencies function after each test, we can now inject modules pretty easily for test purposes. The obvious main caveat is that this approach requires each module to expose inject and reset functions that can be used from the outside. This might or might not work with your current design limitations, but if it does, you can abstract them both into reusable functions and save yourself a lot of boilerplate code.

4.6 函数式风格的模拟

4.6 Mocks in a functional style

让我们深入了解一些可用于将模拟注入到测试代码中的功能样式。

Let’s jump into a few of the functional styles we can use to inject mocks into our code under test.

4.6.1 使用柯里化风格

4.6.1 Working with a currying style

让我们实现第 3 章中介绍的柯里化技术,以对记录器执行更具函数式的注入。在下面的清单中,我们将使用lodash,一个促进 JavaScript 中函数式编程的库,无需太多样板代码即可进行柯里化工作。

Let’s implement the currying technique introduced in chapter 3 to perform a more functional-style injection of our logger. In the following listing, we’ll use lodash, a library that facilitates functional programming in JavaScript, to get currying working without too much boilerplate code.

清单 4.7 将柯里化应用于我们的函数

Listing 4.7 Applying currying to our function

const verifyPassword3 = _ .curry( (规则、记录器、输入) => {
    const 失败 = 规则
        .map(规则=>规则(输入))
        .filter(结果 => 结果 === false);
    if (失败.length === 0) {
        logger.info('通过');
        返回真;
    }
    logger.info('失败');
    返回假;
} ) ;
const verifyPassword3 = _.curry((rules, logger, input) => {
    const failed = rules
        .map(rule => rule(input))
        .filter(result => result === false);
    if (failed.length === 0) {
        logger.info('PASSED');
        return true;
    }
    logger.info('FAIL');
    return false;
});

唯一的变化是在第一行调用_.curry,并在代码块末尾将其关闭。

The only change is the call to _.curry on the first line, and closing it off at the end of the code block.

以下清单演示了此类代码的测试可能是什么样子。

The following listing demonstrates what a test for this type of code might look like.

清单 4.8 使用依赖注入测试柯里化函数

Listing 4.8 Testing a curried function with dependency injection

描述(“密码验证器”,()=> {
  描述(“给定的记录器和传递场景”,()=> {
    it("使用 PASS 调用记录器", () => {
      让记录=“”;
      const mockLog = { 信息: (文本) => (记录 = 文本) };
      const InjectedVerify = verifyPassword3([],mockLog);
 
      // 这个部分应用的函数可以被传递
      // 到代码中的其他地方
      // 无需注入记录器
      注入验证(“任何东西”);
 
      期望(记录)。toMatch(/通过/);
    });
  });
});
describe("password verifier", () => {
  describe("given logger and passing scenario", () => {
    it("calls the logger with PASS", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const injectedVerify = verifyPassword3([], mockLog);
 
      // this partially applied function can be passed around
      // to other places in the code
      // without needing to inject the logger
      injectedVerify("anything");
 
      expect(logged).toMatch(/PASSED/);
    });
  });
});

我们的测试使用前两个参数调用函数(注入ruleslogger依赖项,有效返回部分应用的函数),然后使用injectedVerify最终输入调用返回的函数,从而向读者展示两件事:

Our test invokes the function with the first two arguments (injecting the rules and logger dependencies, effectively returning a partially applied function), and then invokes the returned function injectedVerify with the final input, thus showing the reader two things:

  • 该功能在现实生活中如何使用

  • How this function is meant to be used in real life

  • 依赖项是什么

  • What the dependencies are

除此之外,与之前的测试几乎相同。

Other than that, it’s pretty much the same as in the previous test.

4.6.2 使用高阶函数而不是柯里化

4.6.2 Working with higher-order functions and not currying

清单 4.9 是函数式编程设计的另一种变体。我们使用高阶函数,但没有柯里化。您可以看出以下代码不包含柯里化,因为我们始终需要将所有参数作为参数发送给函数才能使其正常工作。

Listing 4.9 is another variation on the functional programming design. We’re using a higher-order function, but without currying. You can tell that the following code does not contain currying because we always need to send in all of the parameters as arguments to the function for it to be able to work correctly.

清单 4.9 在高阶函数中注入模拟

Listing 4.9 Injecting a mock in a higher-order function

const makeVerifier = (规则, 记录器) => { 
    return (输入) => {                      
        const 失败 = 规则
            .map(规则=>规则(输入))
            .filter(结果 => 结果 === false);
 
        if (失败.length === 0) {
            logger.info('通过');
            返回真;
        }
        logger.info('失败');
        返回假;
    }; 
};
const makeVerifier = (rules, logger) => {
    return (input) => {                     
        const failed = rules
            .map(rule => rule(input))
            .filter(result => result === false);
 
        if (failed.length === 0) {
            logger.info('PASSED');
            return true;
        }
        logger.info('FAIL');
        return false;
    };
};

返回预先配置的验证器

Returning a preconfigured verifier

这次我显式地创建一个工厂函数,该函数返回一个预配置的验证器函数,该函数已在其闭包的依赖项中包含ruleslogger

This time I’m explicitly making a factory function that returns a preconfigured verifier function that already contains the rules and logger in its closure’s dependencies.

现在让我们看一下对此的测试。测试需要首先调用makeVerifier工厂函数,然后调用该函数返回的函数 ( passVerify)。

Now let’s look at the test for this. The test needs to first call the makeVerifier factory function and then call the function that’s returned by that function (passVerify).

清单 4.10 使用工厂函数进行测试

Listing 4.10 Testing using a factory function

描述(“高阶工厂函数”,()=> {
  描述(“密码验证器”,()=> {
    test("给定记录器和传递场景", () => {
      让记录=“”;
      const mockLog = { 信息: (文本) => (记录 = 文本) };
      const passVerify = makeVerifier([],mockLog);        
 
      passVerify("任何输入");                             
 
      期望(记录)。toMatch(/通过/);
    });
  });
});
describe("higher order factory functions", () => {
  describe("password verifier", () => {
    test("given logger and passing scenario", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const passVerify = makeVerifier([], mockLog);        
 
      passVerify("any input");                             
 
      expect(logged).toMatch(/PASSED/);
    });
  });
});

调用工厂函数

Calling the factory function

调用结果函数

Calling the resulting function

4.7 面向对象风格的模拟

4.7 Mocks in an object-oriented style

现在我们已经介绍了一些函数式和模块化样式,让我们看看面向对象的样式。来自面向对象背景的人会对这种类型的方法感到更舒服,而来自功能背景的人会讨厌它。但生活就是要接受人们的差异。

Now that we’ve covered some functional and modular styles, let’s look at the object-oriented styles. People coming from an object-oriented background will feel much more comfortable with this type of approach, and people coming from a functional background will hate it. But life is about accepting people’s differences.

4.7.1 重构注入的生产代码

4.7.1 Refactoring production code for injection

清单 4.11 显示了这种类型的注入在 JavaScript 中基于类的设计中可能是什么样子。类有构造函数,我们使用构造函数来强制类的调用者提供参数。这不是实现这一目标的唯一方法,但它在面向对象的设计中非常常见和有用,因为它使这些参数的要求明确,并且在强类型语言(例如 Java 或 C)以及使用 TypeScript 时实际上是不可否认的。我们希望确保使用我们代码的人都知道正确配置它需要什么。

Listing 4.11 shows what this type of injection might look like in a class-based design in JavaScript. Classes have constructors, and we use the constructor to force the caller of the class to provide parameters. This is not the only way to accomplish that, but it’s very common and useful in an object-oriented design because it makes the requirement of those parameters explicit and practically undeniable in strongly typed languages such as Java or C, and when using TypeScript. We want to make sure whoever uses our code knows what is expected to configure it properly.

清单 4.11 基于类的构造函数注入

Listing 4.11 Class-based constructor injection

类密码验证器 {
  _规则;
  _记录器;
 
 构造函数(规则,记录器){ 
    this. _规则=规则;
    这。_记录器=记录器;
 }
 
  验证(输入){
    const 失败 = this._rules
        .map(规则=>规则(输入))
        .filter(结果 => 结果 === false);
 
    if (失败.length === 0) {
      这。_ logger.info('通过');
      返回真;
    }
    这。_ logger.info('失败');
    返回假;
  }
}
class PasswordVerifier {
  _rules;
  _logger;
 
  constructor(rules, logger) {
    this._rules = rules;
    this._logger = logger;
  }
 
  verify(input) {
    const failed = this._rules
        .map(rule => rule(input))
        .filter(result => result === false);
 
    if (failed.length === 0) {
      this._logger.info('PASSED');
      return true;
    }
    this._logger.info('FAIL');
    return false;
  }
}

这只是一个标准类,它接受几个构造函数参数,然后在函数中使用它们verify。以下清单显示了测试的样子。

This is just a standard class that takes a couple of constructor parameters and then uses them inside the verify function. The following listing shows what a test might look like.

清单 4.12 将模拟记录器作为构造函数参数注入

Listing 4.12 Injecting a mock logger as a constructor parameter

描述(“使用函数构造函数注入进行鸭子打字”,()=> {
  描述(“密码验证器”,()=> {
    test("记录器&通过场景,通过 PASSED 调用记录器", () => {
      让记录=“”;
      const  mockLog = { 信息: (文本) => (记录 = 文本) }; 
      const  verifier = new  PasswordVerifier([],mockLog);
      verifier.verify("任意输入");
 
      期望(记录)。toMatch(/通过/);
    });
  });
});   
describe("duck typing with function constructor injection", () => {
  describe("password verifier", () => {
    test("logger&passing scenario,calls logger with PASSED", () => {
      let logged = "";
      const mockLog = { info: (text) => (logged = text) };
      const verifier = new PasswordVerifier([], mockLog);
      verifier.verify("any input");
 
      expect(logged).toMatch(/PASSED/);
    });
  });
});   

模拟注入很简单,就像我们在上一章中看到的桩一样。如果我们使用属性而不是构造函数,则意味着依赖项是可选的。对于构造函数,我们明确表示它们不是可选的。

Mock injection is straightforward, much like with stubs, as we saw in the previous chapter. If we were to use properties rather than a constructor, it would mean that the dependencies are optional. With a constructor, we’re explicitly saying they’re not optional.

在 Java 或 C# 等强类型语言中,通常将伪造的记录器提取为单独的类,如下所示:

In strongly typed languages like Java or C#, it’s common to extract the fake logger as a separate class, like so:

类 FakeLogger {
  记录=“”;
 
  信息(文本){
    this.logged = 文本;
  }
}
class FakeLogger {
  logged = "";
 
  info(text) {
    this.logged = text;
  }
}

我们只是info在类中实现该函数,但不记录任何内容,而是将作为参数发送给该函数的值保存在一个公开可见的变量中,我们可以在稍后的测试中再次断言该变量。

We simply implement the info function in the class, but instead of logging anything, we just save the value being sent as a parameter to the function in a publicly visible variable that we can assert again later in our test.

请注意,我没有调用假对象MockLoggerStubLoggerbut FakeLogger。这样我就可以在多个不同的测试中重用此类。在某些测试中,它可能用作桩,而在其他测试中,它可能用作模拟对象。我用“假”这个词来表示任何不真实的东西。此类事情的另一个常见术语是“测试替身”。假货较短,所以我喜欢。

Notice that I didn’t call the fake object MockLogger or StubLogger but FakeLogger. This is so that I can reuse this class in multiple different tests. In some tests, it might be used as a stub, and in others it might be used as a mock object. I use the word “fake” to denote anything that isn’t real. Another common term for this sort of thing is “test double.” Fake is shorter, so I like it.

在我们的测试中,我们将实例化该类并将其作为构造函数参数发送,然后我们将对logged该类的变量进行断言,如下所示:

In our tests, we’ll instantiate the class and send it over as a constructor parameter, and then we’ll assert on the logged variable of the class, like so:

test("记录器 + 通过场景,使用 PASSED 调用记录器", () => {
   让记录=“”;
   const mockLog = new FakeLogger();
   const verifier = new PasswordVerifier([],mockLog);
   verifier.verify("任意输入");
 
   期望(mockLog.logged)。toMatch(/通过/);
});
test("logger + passing scenario, calls logger with PASSED", () => {
   let logged = "";
   const mockLog = new FakeLogger();
   const verifier = new PasswordVerifier([], mockLog);
   verifier.verify("any input");
 
   expect(mockLog.logged).toMatch(/PASSED/);
});

4.7.2 通过接口注入重构生产代码

4.7.2 Refactoring production code with interface injection

接口在许多面向对象的程序中发挥着重要作用。它们是多态性思想的一种变体:只要一个或多个对象实现相同的接口,就允许它们相互替换。在 JavaScript 和 Ruby 等其他语言中,不需要接口,因为该语言允许鸭子类型的想法,而无需将对象强制转换为特定接口。我不会在这里讨论鸭子类型的优点和缺点。您应该能够以您选择的语言使用您认为合适的任何一种技术。在 JavaScript 中,我们可以转向 TypeScript 来使用接口。我们将使用的编译器或转换器可以帮助确保我们正确使用基于签名的类型。

Interfaces play a large role in many object-oriented programs. They are one variation on the idea of polymorphism: allowing one or more objects to be replaced with one another as long as they implement the same interface. In JavaScript and other languages like Ruby, interfaces are not needed, since the language allows for the idea of duck typing without needing to cast an object to a specific interface. I won’t touch here on the pros and cons of duck typing. You should be able to use either technique as you see fit, in the language of your choice. In JavaScript, we can turn to TypeScript to use interfaces. The compiler, or transpiler, we’ll use can help ensure that we are using types based on their signatures correctly.

清单 4.13 显示了三个代码文件:第一个描述了一个新ILogger接口,第二个描述了SimpleLogger实现该接口的 ,第三个是 our PasswordVerifier,它仅使用该ILogger接口来获取记录器实例。PasswordVerifier不知道被注入的记录器的实际类型。

Listing 4.13 shows three code files: the first describes a new ILogger interface, the second describes a SimpleLogger that implements that interface, and the third is our PasswordVerifier, which uses only the ILogger interface to get a logger instance. PasswordVerifier has no knowledge of the actual type of logger being injected.

清单 4.13 生产代码获取一个ILogger接口

Listing 4.13 Production code gets an ILogger interface

导出接口 ILogger {                                 
    info(text: string);                                   
}                                                          
 
//此类可能依赖于文件或网络
SimpleLogger 类实现 ILogger {                    
    信息(文本:字符串){
    }
}
 
导出类密码验证器{
    私有_规则:任何[];
    私有_记录器:ILogger;                             
 
    构造函数(规则:any[],记录器:ILogger){           
        this._rules = 规则;
        这。_记录器=记录器;                            
    }
 
    验证(输入:字符串):布尔值{
        const 失败 = this._rules
            .map(规则=>规则(输入))
            .filter(结果 => 结果 === false);
 
        if (失败.length === 0) {
            这。_ logger.info('通过');
            返回真;
        }
        这。_ logger.info('失败');
        返回假;
    }
}
export interface ILogger {                                
    info(text: string);                                   
}                                                         
 
//this class might have dependencies on files or network
class SimpleLogger implements ILogger {                   
    info(text: string) {
    }
}
 
export class PasswordVerifier {
    private _rules: any[];
    private _logger: ILogger;                             
 
    constructor(rules: any[], logger: ILogger) {          
        this._rules = rules;
        this._logger = logger;                            
    }
 
    verify(input: string): boolean {
        const failed = this._rules
            .map(rule => rule(input))
            .filter(result => result === false);
 
        if (failed.length === 0) {
            this._logger.info('PASSED');
            return true;
        }
        this._logger.info('FAIL');
        return false;
    }
}

一个新的界面,它是生产代码的一部分

A new interface, which is part of production code

记录器现在实现了该接口。

The logger now implements that interface.

验证者现在使用该接口。

The verifier now uses the interface.

请注意,生产代码中发生了一些变化。我已在生产代码中添加了一个新接口,现有记录器现在实现了该接口。我正在更改设计以使记录器可更换。此外,PasswordVerifier类与接口而不是SimpleLogger类一起工作。这允许我用假实例替换类的实例logger,而不是对真实记录器有硬依赖。

Notice that a few things have changed in the production code. I’ve added a new interface to the production code, and the existing logger now implements this interface. I’m changing the design to make the logger replaceable. Also, the PasswordVerifier class works with the interface instead of the SimpleLogger class. This allows me to replace the instance of the logger class with a fake one, instead of having a hard dependency on the real logger.

以下清单显示了强类型语言中的测试可能是什么样子,但使用了实现接口的手写假对象ILogger

The following listing shows what a test might look like in a strongly typed language, but with a handwritten fake object that implements the ILogger interface.

清单 4.14 注入手写的模拟ILogger

Listing 4.14 Injecting a handwritten mock ILogger

类 FakeLogger 实现 ILogger {
    书面:字符串;
    信息(文本:字符串){ 
        this.writing = 文本;
    } 
}
描述('带有接口的密码验证器',()=> {
    test('验证,使用记录器,调用记录器', () => {
        const mockLog = new FakeLogger(); 
        const verifier = new PasswordVerifier ([], mockLog );
 
        verifier.verify('任何东西');
 
        期望( mockLog.writing ).toMatch(/PASS/);
    });
});
class FakeLogger implements ILogger {
    written: string;
    info(text: string) {
        this.written = text;
    }
}
describe('password verifier with interfaces', () => {
    test('verify, with logger, calls logger', () => {
        const mockLog = new FakeLogger();
        const verifier = new PasswordVerifier([], mockLog);
 
        verifier.verify('anything');
 
        expect(mockLog.written).toMatch(/PASS/);
    });
});

在此示例中,我创建了一个名为 的手写类FakeLogger。它所做的只是重写ILogger接口中的一个方法并保存text参数以供将来断言。然后我们将该值公开为类中的字段written。一旦暴露该值,我们就可以通过检查该字段来验证是否调用了假记录器。

In this example, I’ve created a handwritten class called FakeLogger. All it does is override the one method in the ILogger interface and save the text parameter for future assertion. We then expose this value as a field in the written class. Once this value is exposed, we can verify that the fake logger was called by checking that field.

我手动完成此操作是因为我希望您看到即使在面向对象的领域中,模式也会重复出现。我们现在拥有一个模拟对象,而不是一个模拟函数,但代码和测试的工作方式与前面的示例相同。

I’ve done this manually because I wanted you to see that even in object-oriented land, the patterns repeat themselves. Instead of having a mock function, we now have a mock object, but the code and test work just like the previous examples.

接口命名约定

Interface naming conventions

我使用的命名约定是在记录器接口前加上“I”前缀,因为它将用于多态原因(即,我使用它来抽象系统中的角色)。TypeScript 中的接口命名并非总是如此,例如当我们使用接口来定义一组参数的结构时(基本上将它们用作强类型结构)。在这种情况下,不带“我”的命名对我来说是有意义的。

I’m using the naming convention of prefixing the logger interface with an “I” because it’s going to be used for polymorphic reasons (i.e., I’m using it to abstract a role in the system). This is not always the case for interface naming in TypeScript, such as when we use interfaces to define the structure of a set of parameters (basically using them as strongly typed structures). In that case, naming without an “I” makes sense to me.

现在,可以这样想:如果您要多次实现它,则应该在它前面加上“I”前缀,以使接口的预期用途更加明确。

For now, think of it like this: If you’re going to implement it more than once, you should prefix it with an “I” to make the expected use of the interface more explicit.

4.8 处理复杂的接口

4.8 Dealing with complicated interfaces

当接口比较复杂时,例如当其中包含超过一两个函数,或者每个函数中超过一两个参数时,会发生什么情况?

What happens when the interface is more complicated, such as when it has more than one or two functions in it, or more than one or two parameters in each function?

4.8.1 复杂接口示例

4.8.1 Example of a complicated interface

清单 4.15 是这种复杂接口的示例,也是使用复杂记录器(作为接口注入)的生产代码验证器的示例。该IComplicatedLogger接口有四个函数,每个函数都有一个或多个参数。每个函数都需要在我们的测试中伪造,这可能会导致我们的代码和测试中的复杂性和可维护性问题。

Listing 4.15 is an example of such a complicated interface, and of the production code verifier that uses the complicated logger, injected as an interface. The IComplicatedLogger interface has four functions, each with one or more parameters. Every function would need to be faked in our tests, and that can lead to complexity and maintainability problems in our code and tests.

清单 4.15 使用更复杂的接口(生产代码)

Listing 4.15 Working with a more complicated interface (production code)

导出接口IComplicatedLogger {                         
    info (text: string)
     debug (text: string, obj: any)
     warn (text: string)
     error (text: string, location: string, stacktrace: string)
}
 
导出类密码验证器2 {
    私有_规则:任何[];
    私人_logger:IComplicatedLogger
 
    构造函数(规则:any[],记录器:IComplicatedLogger){   
        this._rules = 规则;
        this._logger = 记录器;
    }
...
}
export interface IComplicatedLogger {                        
    info(text: string)
    debug(text: string, obj: any)
    warn(text: string)
    error(text: string, location: string, stacktrace: string)
}
 
export class PasswordVerifier2 {
    private _rules: any[];
    private _logger: IComplicatedLogger;                     
 
    constructor(rules: any[], logger: IComplicatedLogger) {  
        this._rules = rules;
        this._logger = logger;
    }
...
}

一个新的界面,它是生产代码的一部分

A new interface, which is part of production code

该类现在可以使用新接口。

The class now works with the new interface.

正如您所看到的,新IComplicatedLogger界面将成为生产代码的一部分,这将使其logger可替换。我将省略真实记录器的实现,因为它与我们的示例无关。这就是使用接口抽象事物的好处:我们不需要直接引用它们。另请注意,类的构造函数中期望的参数类型是接口的类型IComplicatedLogger。这允许我用假的记录器类实例替换记录器类的实例,就像我们之前所做的那样。

As you can see, the new IComplicatedLogger interface will be part of production code, which will make the logger replaceable. I’m leaving off the implementation of a real logger, because it’s not relevant for our examples. That’s the benefit of abstracting away things with an interface: we don’t need to reference them directly. Also notice that the type of parameter expected in the class’s constructor is that of the IComplicatedLogger interface. This allows me to replace the instance of the logger class with a fake one, just like we did before.

4.8.2 编写具有复杂接口的测试

4.8.2 Writing tests with complicated interfaces

这是测试的样子。它必须重写每个接口函数,这会创建又长又烦人的样板代码。

Here’s what the test looks like. It has to override each and every interface function, which creates long and annoying boilerplate code.

清单 4.16 带有复杂记录器接口的测试代码

Listing 4.16 Test code with a complicated logger interface

描述(“使用长接口”,()=> {
  描述(“密码验证器”,()=> {
    类 FakeComplicatedLogger             
        实现 IComplicatedLogger {     
      infoWritten = ""; 
      调试写入=“”;
      错误写入=“”;
      警告写入=“”;
 
      调试(文本:字符串,对象:任意){ 
        this.debugWritten = 文本;
      }
 
      错误(文本:字符串,位置:字符串,堆栈跟踪:字符串){ 
        this.errorWritten = 文本;
      }
 
      信息(文本:字符串){ 
        this.infoWritten = 文本;
      }
 
      警告(文本:字符串){ 
        this.warnWritten = 文本;
      } 
    }
    ...
 
    test("验证通过,使用记录器,使用 PASS 调用记录器", () => {
      const mockLog = new FakeComplicatedLogger();
 
      constverifier=newPasswordVerifier2([],mockLog);
      verifier.verify("任何东西");
 
      期望(mockLog.infoWritten).toMatch(/通过/);
    });
 
    test("此测试的更面向 JS 的变体", () => {
      const mockLog = {} 作为 IComplicatedLogger;
      让记录=“”;
      mockLog.info = (文本) => (记录 = 文本);
 
      constverifier=newPasswordVerifier2([],mockLog);
      verifier.verify("任何东西");
 
      期望(记录)。toMatch(/通过/);
    });
  });
});
describe("working with long interfaces", () => {
  describe("password verifier", () => {
    class FakeComplicatedLogger            
        implements IComplicatedLogger {    
      infoWritten = "";
      debugWritten = "";
      errorWritten = "";
      warnWritten = "";
 
      debug(text: string, obj: any) {
        this.debugWritten = text;
      }
 
      error(text: string, location: string, stacktrace: string) {
        this.errorWritten = text;
      }
 
      info(text: string) {
        this.infoWritten = text;
      }
 
      warn(text: string) {
        this.warnWritten = text;
      }
    }
    ...
 
    test("verify passing, with logger, calls logger with PASS", () => {
      const mockLog = new FakeComplicatedLogger();
 
      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");
 
      expect(mockLog.infoWritten).toMatch(/PASSED/);
    });
 
    test("A more JS oriented variation on this test", () => {
      const mockLog = {} as IComplicatedLogger;
      let logged = "";
      mockLog.info = (text) => (logged = text);
 
      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");
 
      expect(logged).toMatch(/PASSED/);
    });
  });
});

实现新接口的假记录器类

A fake logger class that implements the new interface

在这里,我们再次声明一个FakeComplicatedLogger实现该IComplicatedLogger接口的假记录器类 ( )。看看我们有多少样板代码。如果我们使用强类型的面向对象语言(例如 Java、C# 或 C++),这一点尤其正确。有很多方法可以解决所有这些样板代码,我们将在下一章中讨论。

Here, we’re declaring, again, a fake logger class (FakeComplicatedLogger) that implements the IComplicatedLogger interface. Look at how much boilerplate code we have. This will be especially true if we’re working in strongly typed object-oriented languages such as Java, C#, or C++. There are ways around all this boilerplate code, which we’ll touch on in the next chapter.

4.8.3 直接使用复杂接口的缺点

4.8.3 Downsides of using complicated interfaces directly

在我们的测试中使用又长又复杂的接口还有其他缺点:

There are other downsides to using long, complicated interfaces in our tests:

  • 如果我们保存手动发送的参数,则在多个方法和调用中验证多个参数会更加麻烦。

  • If we’re saving arguments being sent in manually, it’s more cumbersome to verify multiple arguments across multiple methods and calls.

  • 我们很可能依赖第三方接口而不是内部接口,随着时间的推移,这最终会使我们的测试变得更加脆弱。

  • It’s likely that we are depending on third-party interfaces instead of internal ones, and this will end up making our tests more brittle as time goes by.

  • 即使我们依赖内部接口,长接口也有更多改变的理由,现在我们的测试也是如此。

  • Even if we are depending on internal interfaces, long interfaces have more reasons to change, and now so do our tests.

这对我们意味着什么?我强烈建议仅使用满足这两个条件的假接口:

What does this mean for us? I highly recommend using only fake interfaces that meet both of these conditions:

  • 您控制界面(它们不是由第三方制作)。

  • You control the interfaces (they are not made by a third party).

  • 它们适合您的工作单元或组件的需求。

  • They are adapted to the needs of your unit of work or component.

4.8.4 接口隔离原则

4.8.4 The interface segregation principle

上述条件中的第二个可能需要一些解释。它与接口隔离原则有关(ISP;https://en.wikipedia.org/wiki/Interface_segregation_principle)。ISP 意味着,如果我们有一个接口包含比我们需要的更多的功能,我们应该创建一个小而简单的适配器接口,其中只包含我们需要的功能,最好具有更少的功能、更好的名称和更少的参数。

The second of the preceding conditions might need a bit of explanation. It relates to the interface segregation principle (ISP; https://en.wikipedia.org/wiki/Interface_segregation_principle). ISP means that if we have an interface that contains more functionality than we require, we should create a small, simpler adapter interface that contains just the functionality we need, preferably with fewer functions, better names, and fewer parameters.

这最终将使我们的测试变得更加简单。通过抽象出真正的依赖关系,当复杂的接口发生变化时,我们不需要改变我们的测试——只需要在某个地方改变一个适配器类文件。我们将在第 5 章中看到一个例子。

This will end up making our tests much simpler. By abstracting away the real dependencies, we won’t need to change our tests when the complicated interfaces change—only a single adapter class file somewhere. We’ll see an example of this in chapter 5.

4.9 部分模拟

4.9 Partial mocks

在 JavaScript 和大多数其他语言以及相关的测试框架中,可以接管现有的对象和函数并“监视”它们。通过监视它们,我们可以稍后检查它们是否被调用、调用了多少次以及使用了哪些参数。

It’s possible, in JavaScript and in most other languages and associated test frameworks, to take over existing objects and functions and “spy” on them. By spying on them, we can later check if they were called, how many times, and with which arguments.

这本质上可以将真实对象的一部分转换为模拟函数,同时保持对象的其余部分作为真实对象。这可能会创建更复杂、更脆弱的测试,但有时它可能是一个可行的选择,特别是如果您正在处理遗留代码(有关遗留代码的更多信息,请参阅第 12 章)。

This essentially can turn parts of a real object into mock functions, while keeping the rest of the object as a real object. This can create more complicated tests that are more brittle, but it can sometimes be a viable option, especially if you’re dealing with legacy code (see chapter 12 for more on legacy code).

4.9.1 部分模拟的功能示例

4.9.1 A functional example of a partial mock

以下清单显示了此类测试的外观。我们创建真正的记录器,然后我们只需使用自定义函数覆盖其现有的实际函数之一。

The following listing shows what such a test might look like. We create the real logger, and then we simply override one of its existing real functions using a custom function.

清单 4.17 部分模拟示例

Listing 4.17 A partial mock example

描述(“带有接口的密码验证器”,()=> {
  test("验证,使用记录器,调用记录器", () => {
    const testableLog: RealLogger = new RealLogger();   
    让记录=“”;
    testableLog.info = (文本) => (记录 = 文本);       
 
    const verifier = new PasswordVerifier([], testableLog);
    verifier.verify("任意输入");
 
    期望(记录)。toMatch(/通过/);
  });
});
describe("password verifier with interfaces", () => {
  test("verify, with logger, calls logger", () => {
    const testableLog: RealLogger = new RealLogger();   
    let logged = "";
    testableLog.info = (text) => (logged = text);       
 
    const verifier = new PasswordVerifier([], testableLog);
    verifier.verify("any input");
 
    expect(logged).toMatch(/PASSED/);
  });
});

实例化一个真实的记录器

Instantiating a real logger

模拟其功能之一

Mocking one of its functions

在此测试中,我将实例化一个RealLogger,并在下一行中将其现有函数之一替换为假函数。更具体地说,我使用一个模拟函数,它允许我使用自定义变量跟踪其最新的调用参数。

In this test, I’m instantiating a RealLogger, and in the next line I’m replacing one of its existing functions with a fake one. More specifically, I’m using a mock function that allows me to track its latest invocation parameter using a custom variable.

这里重要的部分是该testableLog变量是部分模拟。这意味着至少它的一些内部实现不是假的,并且可能具有真正的依赖关系和逻辑。

The important part here is that the testableLog variable is a partial mock. That means that at least some of its internal implementation is not fake and might have real dependencies and logic in it.

有时使用部分模拟是有意义的,特别是当您使用遗留代码并且可能需要将某些现有代码与其依赖项隔离时。我将在第 12 章中详细讨论这一点。

Sometimes it makes sense to use partial mocks, especially when you’re working with legacy code and you might need to isolate some existing code from its dependencies. I’ll touch more on that in chapter 12.

4.9.2 面向对象的部分模拟示例

4.9.2 An object-oriented partial mock example

部分模拟的一种面向对象版本使用继承来覆盖真实类中的函数,以便我们可以验证它们是否被调用。下面的清单显示了我们如何使用 JavaScript 中的继承和覆盖来做到这一点。

One object-oriented version of a partial mock uses inheritance to override functions from real classes so that we can verify they were called. The following listing shows how we can do this using inheritance and overrides in JavaScript.

清单 4.18 一个面向对象的部分模拟示例

Listing 4.18 An object-oriented partial mock example

类 TestableLogger 扩展了 RealLogger {     ❶logging 
  = ""; 
  信息(文本){                                
    this.logged = 文本;                      
  }                                           
  // error() 和 debug() 函数
  // 仍然是“真实的”
}
 
描述(“带有继承的部分模拟”,()=> {
  test("使用记录器验证,调用记录器", () => {
    const mockLog: TestableLogger = new TestableLogger();
 
    const verifier = new PasswordVerifier([], mockLog );
    verifier.verify("任意输入");
 
    期望( mockLog.logged ).toMatch(/PASSED/);
  });
});
class TestableLogger extends RealLogger {    
  logged = "";
  info(text) {                               
    this.logged = text;                      
  }                                          
  // the error() and debug() functions
  // are still "real"
}
 
describe("partial mock with inheritance", () => {
  test("verify with logger, calls logger", () => {
    const mockLog: TestableLogger = new TestableLogger();
 
    const verifier = new PasswordVerifier([], mockLog);
    verifier.verify("any input");
 
    expect(mockLog.logged).toMatch(/PASSED/);
  });
});

继承真实logger

Inheriting from the real logger

重写其函数之一

Overriding one of its functions

我在测试中继承了真实的记录器类,然后在测试中使用继承的类,而不是原始类。这种技术通常称为提取和覆盖,您可以在 Michael Feathers 的著作《有效处理遗留代码》(Pearson,2004 年)中找到更多相关信息。

I inherit from the real logger class in my tests and then use the inherited class, not the original class, in my tests. This technique is commonly called Extract and Override, and you can find more about this in Michael Feathers’ book Working Effectively with Legacy Code (Pearson, 2004).

请注意,我将假记录器类命名为“TestableXXX”,因为它是真实生产代码的可测试版本,包含假代码和真实代码的混合,并且此约定帮助我向读者明确说明这一点。我还将课程与考试放在一起。我的生产代码不需要知道这个类的存在。这种提取和覆盖样式要求生产代码中的类允许继承并且该函数允许覆盖。在 JavaScript 中,这不是一个问题,但在 Java 和 C# 中,这些是需要做出的显式设计选择(尽管有一些框架允许我们规避此规则;我们将在下一章中讨论它们)。

Note that I’ve named the fake logger class “TestableXXX” because it’s a testable version of real production code, containing a mix of fake and real code, and this convention helps me make this explicit for the reader. I also put the class right alongside my tests. My production code doesn’t need to know that this class exists. This Extract and Override style requires that my class in production code allows inheritance and that the function allows overriding. In JavaScript this is less of an issue, but in Java and C# these are explicit design choices that need to be made (although there are frameworks that allow us to circumvent this rule; we’ll discuss them in the next chapter).

在这种情况下,我们继承了一个不直接测试的类 ( RealLogger)。我们使用该类来测试另一个类 ( PasswordVerifier)。然而,这种技术可以非常有效地用于从您直接测试的类中隔离和桩或模拟单个函数。当我们讨论遗留代码和重构技术时,我们将在本书后面详细讨论这一点。

In this scenario, we’re inheriting from a class that we’re not testing directly (RealLogger). We use that class to test another class (PasswordVerifier). However, this technique can be used quite effectively to isolate and stub or mock single functions from classes that you’re directly testing. We’ll touch more on that later in the book when we talk about legacy code and refactoring techniques.

概括

Summary

  • 交互测试是一种检查工作单元如何与其传出依赖项交互的方法:进行了哪些调用以及使用了哪些参数。交互测试涉及第三种类型的出口点:第三方模块、对象或系统。(前两种类型是返回值和状态改变。)

  • Interaction testing is a way to check how a unit of work interacts with its outgoing dependencies: what calls were made and with which parameters. Interaction testing relates to the third type of exit points: a third-party module, object, or system. (The first two types are a return value and a state change.)

  • 要进行交互测试,您应该使用模拟,它们是替换传出依赖项的测试替身。替换传入的依赖项。您应该在测试中验证与模拟的交互,而不是与桩的交互。与模拟不同,与桩的交互是实现细节,不应检查。

  • To do interaction testing, you should use mocks, which are test doubles that replace outgoing dependencies. Stubs replace incoming dependencies. You should verify interactions with mocks in tests, but not with stubs. Unlike with mocks, interactions with stubs are implementation details and shouldn't be checked.

  • 在一个测试中拥有多个桩是可以的,但您通常不希望每个测试有多个模拟,因为这意味着您在单个测试中测试多个需求。

  • It’s OK to have multiple stubs in a test, but you don’t usually want to have more than a single mock per test, because that means you’re testing more than one requirement in a single test.

  • 就像桩一样,有多种方法可以将模拟注入到工作单元中:

    • 标准——通过引入参数

    • 功能性——使用部分应用程序或工厂功能

    • 模块化——抽象模块依赖

    • 面向对象——使用无类型对象(在 JavaScript 等语言中)或类型化接口(在 TypeScript 中)

  • Just like with stubs, there are multiple ways to inject a mock into a unit of work:

    • Standard—By introducing a parameter

    • Functional—Using a partial application or factory functions

    • Modular—Abstracting the module dependency

    • Object-oriented—Using an untyped object (in languages like JavaScript) or a typed interface (in TypeScript)

  • 在JavaScript中,可以部分实现复杂的接口,这有助于减少样板文件的数量。还可以选择使用部分模拟,您可以从真实的类继承并仅用假类替换其某些方法。

  • In JavaScript, a complicated interface can be implemented partially, which helps reduce the amount of boilerplate. There’s also the option of using partial mocks, where you inherit from a real class and replace only some of its methods with fakes.

5 隔离框架

5 Isolation frameworks

本章涵盖

This chapter covers

  • 定义隔离框架以及它们如何提供帮助
  • Defining isolation frameworks and how they help
  • 框架的两种主要风格
  • Two main flavors of frameworks
  • 用 Jest 伪造模块
  • Faking modules with Jest
  • 用 Jest 伪造函数
  • Faking functions with Jest
  • 使用 Replace.js 进行面向对象的伪造
  • Object-oriented fakes with substitute.js

在前面的章节中,我们研究了手动编写模拟和桩,并看到了所涉及的挑战,特别是当我们想要伪造的接口要求我们创建长的、容易出错的重复代码时。我们一直不得不声明自定义变量,创建自定义函数,或者从使用这些变量的类继承,并且基本上使事情变得比需要的更复杂(大多数时候)。

In the previous chapters, we looked at writing mocks and stubs manually and saw the challenges involved, especially when the interface we’d like to fake requires us to create long, error prone, repetitive code. We kept having to declare custom variables, create custom functions, or inherit from classes that use those variables and basically make things a bit more complicated than they need to be (most of the time).

在本章中,我们将以隔离框架的形式研究这些问题的一些优雅的解决方案——一个可以在运行时创建和配置假对象的可重用库。这些对象称为动态桩动态模拟

In this chapter, we’ll look at some elegant solutions to these problems in the form of an isolation framework—a reusable library that can create and configure fake objects at run time. These objects are referred to as dynamic stubs and dynamic mocks.

我将它们称为隔离框架,因为它们允许您将工作单元与其依赖项隔离。您会发现许多资源将它们称为“模拟框架”,但我尽量避免这种情况,因为它们可以用于模拟和桩。在本章中,我们将了解一些可用的 JavaScript 框架,以及如何在模块化、函数式和面向对象的设计中使用它们。您将看到如何使用此类框架来测试各种事物并创建桩、模拟和其他有趣的事物。

I call them isolation frameworks because they allow you to isolate the unit of work from its dependencies. You’ll find that many resources will refer to them as “mocking frameworks,” but I try to avoid that because they can be used for both mocks and stubs. In this chapter, we’ll take a look at a few of the JavaScript frameworks available and how we can use them in modular, functional, and object-oriented designs. You’ll see how you can use such frameworks to test various things and to create stubs, mocks, and other interesting things.

但我在这里介绍的具体框架不是重点。在使用它们时,您将看到它们的 API 在您的测试中所带来的价值(可读性、可维护性、健壮且持久的测试等等),并且您将发现隔离框架的优点是什么,或者是什么可能会成为您测试的一个缺点。

But the specific frameworks I’ll present here aren’t the point. While using them, you’ll see the values that their APIs promote in your tests (readability, maintainability, robust and long-lasting tests, and more), and you’ll find out what makes an isolation framework good and, alternatively, what can make it a drawback for your tests.

5.1 定义隔离框架

5.1 Defining isolation frameworks

我将从一个听起来有点平淡的基本定义开始,但它需要通用才能包含各种隔离框架:

I’ll start with a basic definition that may sound a bit bland, but it needs to be generic in order to include the various isolation frameworks out there:

隔离框架是一组可编程 API,允许以对象或函数形式动态创建、配置和验证模拟和桩。使用隔离框架时,这些任务通常比手工编码的模拟和桩更简单、更快,并且生成的代码更短。

An isolation framework is a set of programmable APIs that allow the dynamic creation, configuration, and verification of mocks and stubs, either in object or function form. When using an isolation framework, these tasks are often simpler, quicker, and produce shorter code than hand-coded mocks and stubs.

如果使用得当,隔离框架可以使开发人员免于编写重复的代码来断言或模拟对象交互,如果应用在正确的地方,它们可以帮助使测试持续多年,而不需要开发人员回来修复它们在每次小的生产代码更改之后。如果它们应用不当,可能会导致混乱和对这些框架的全面滥用,以至于我们无法阅读或无法信任我们自己的测试,所以要小心。我将在本书的第三部分中讨论一些注意事项。

Isolation frameworks, when used properly, can save developers from the need to write repetitive code to assert or simulate object interactions, and if applied in the right places, they can help make tests last many years without requiring a developer to come back and fix them after every little production code change. If they’re applied badly, they can cause confusion and full-on abuse of these frameworks, to the point where we either can’t read or can’t trust our own tests, so be wary. I’ll discuss some dos and don’ts in part 3 of this book.

5.1.1 选择风格:松散与类型

5.1.1 Choosing a flavor: Loose vs. typed

由于 JavaScript 支持多种编程设计范式,因此我们可以将世界中的框架分为两种主要类型:

Because JavaScript supports multiple paradigms of programming design, we can split the frameworks in our world into two main flavors:

  • 松散 JavaScript 隔离框架——这些是原生 JavaScript 友好的松散类型隔离框架(例如 Jest 和 Sinon)。这些框架通常也更适合更实用的代码风格,因为它们需要更少的仪式和样板代码来完成工作。

  • Loose JavaScript isolation frameworks—These are vanilla JavaScript-friendly loose-typed isolation frameworks (such as Jest and Sinon). These frameworks usually also lend themselves better to more functional styles of code because they require less ceremony and boilerplate code to do their work.

  • 类型化 JavaScript 隔离框架——这些是更加面向对象且对 TypeScript 友好的隔离框架(例如替代.js)。它们在处理整个类和接口时非常有用。

  • Typed JavaScript isolation frameworks—These are more object-oriented and TypeScript-friendly isolation frameworks (such as substitute.js). They’re very useful when dealing with whole classes and interfaces.

您最终选择在项目中使用哪种风格取决于一些因素,例如品味、风格和可读性,但首先的主要问题是,您最需要伪造什么类型的依赖项?

Which flavor you end up choosing to use in your project will depend on a few things, like taste, style, and readability, but the main question to start with is, what type of dependencies will you mostly need to fake?

  • 模块依赖项(导入、需要) ——Jest 和其他松散类型框架应该可以很好地工作。

  • Module dependencies (imports, requires)—Jest and other loosely typed frameworks should work well.

  • 函数式(单阶和高阶函数、简单参数和值) ——Jest 和其他松散类型框架应该运行良好。

  • Functional (single and higher-order functions, simple parameters and values)—Jest and other loosely typed frameworks should work well.

  • 完整的对象、对象层次结构和接口——研究更多面向对象的框架,例如stitute.js。

  • Full objects, object hierarchies, and interfaces—Look into the more object-oriented frameworks, such as substitute.js.

让我们回到密码验证器,看看如何伪造与前几章中相同类型的依赖项,但这次使用框架。

Let’s go back to our Password Verifier and see how we can fake the same types of dependencies we did in previous chapters, but this time using a framework.

5.2 动态伪造模块

5.2 Faking modules dynamically

对于那些尝试使用require或来测试直接依赖于模块的代码的人import来说,Jest 或 Sinon 等隔离框架提供了用很少的代码动态伪造整个模块的强大能力。由于我们一开始使用 Jest 作为测试框架,因此我们将在本章的示例中继续使用它。

For people who are trying to test code with direct dependencies on modules using require or import, isolation frameworks such as Jest or Sinon present the powerful ability to fake an entire module dynamically, with very little code. Since we started with Jest as our test framework, we’ll stick with it for the examples in this chapter.

图 5.1 说明了具有两个依赖项的密码验证器:

Figure 5.1 illustrates a Password Verifier with two dependencies:

  • 帮助确定日志记录级别(INFOERROR)的配置服务

  • A configuration service that helps decide what the logging level is (INFO or ERROR)

  • 每当我们验证密码时,我们将其称为工作单元的退出点的日志记录服务

  • A logging service that we call as the exit point of our unit of work, whenever we verify a password

05-01



图 5.1 密码验证器有两个依赖项:传入的依赖项用于确定日志记录级别,传出的依赖项用于创建日志条目。

Figure 5.1 Password Verifier has two dependencies: an incoming one to determine the logging level, and an outgoing one to create a log entry.

箭头表示通过工作单元的行为流程。思考箭头的另一种方法是通过术语commandquery。我们正在查询配置服务(以获取日志级别),但我们正在向记录器发送命令(以记录日志)。

The arrows represent the flow of behavior through the unit of work. Another way to think about the arrows is through the terms command and query. We are querying the configuration service (to get the log level), but we are sending commands to the logger (to log).

命令/查询分离

Command/query separation

有一个设计流派属于命令/查询分离的思想。如果您想了解有关这些术语的更多信息,我强烈建议您阅读 Martin Fowler 2005 年关于该主题的文章,网址为:https://martinfowler.com/bliki/CommandQuerySeparation.xhtml。当您探索不同的设计理念时,这种模式非常有用,但我们不会在本书中过多涉及这一点。

There is a school of design that falls under the ideas of command/query separation. If you’d like to learn more about these terms, I highly recommend reading Martin Fowler’s 2005 article on the topic, at https://martinfowler.com/bliki/CommandQuerySeparation.xhtml. This pattern is very beneficial as you navigate your way around different design ideas, but we won’t be touching on this too much in this book.

以下清单显示了对记录器模块具有硬依赖性的密码验证器。

The following listing shows a Password Verifier that has a hard dependency on a logger module.

清单 5.1 具有硬编码模块依赖关系的代码

Listing 5.1 Code with hardcoded modular dependencies

const { info, debug } = require("./complicated-logger"); 
const { getLogLevel } = require("./configuration-service");
 
常量日志=(文本)=> {
  如果(getLogLevel()===“信息”){
    信息(文本);
  }
  if (getLogLevel() === "调试") {
    调试(文本);
  }
};
 
const verifyPassword = (输入, 规则) => {
  const 失败 = 规则
    .map((规则) => 规则(输入))
    .filter((结果) => 结果 === false);
 
  if (失败.length === 0) {
    日志(“通过”);
    返回真;
  }
  日志(“失败”);
  返回假;
};
const { info, debug } = require("./complicated-logger");
const { getLogLevel } = require("./configuration-service");
 
const log = (text) => {
  if (getLogLevel() === "info") {
    info(text);
  }
  if (getLogLevel() === "debug") {
    debug(text);
  }
};
 
const verifyPassword = (input, rules) => {
  const failed = rules
    .map((rule) => rule(input))
    .filter((result) => result === false);
 
  if (failed.length === 0) {
    log("PASSED");
    return true;
  }
  log("FAIL");
  return false;
};

在这个例子中,我们被迫找到一种方法来做两件事:

In this example we’re forced to find a way to do two things:

  • 模拟从configuration服务getLogLevel函数返回的(桩)值。

  • Simulate (stub) values returned from the configuration service’s getLogLevel function.

  • 验证(模拟)logger模块的info函数是否被调用。

  • Verify (mock) that the logger module’s info function was called.

图 5.2 直观地展示了这一点。

Figure 5.2 shows a visual representation of this.

05-02



图 5.2 测试桩传入依赖项(配置服务)并模拟传出依赖项(记录器)。

Figure 5.2 The test stubs an incoming dependency (the configuration service) and mocks the outgoing dependency (the logger).

Jest 向我们提供了几种完成模拟和验证的方法,其中一种更简洁的方法是jest.mock([module name])在规范文件的顶部使用,然后我们在测试中需要假模块,以便我们可以配置它们。

Jest presents us with a few ways to accomplish both simulation and verification, and one of the cleaner ways it presents is using jest.mock([module name]) at the top of the spec file, followed by us requiring the fake modules in our tests so that we can configure them.

清单 5.2 直接伪造模块 APIjest.mock()

Listing 5.2 Faking the module APIs directly with jest.mock()

jest.mock("./complicated-logger");                              
jest.mock("./configuration-service");                           
 
const { 字符串匹配 } = 期望;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./complicated-logger");       
const stubConfigModule = require("./configuration-service");    
 
描述(“密码验证器”,()=> {
  afterEach(jest.resetAllMocks);                                
 
  test('有信息日志级别且没有规则,
          它使用 PASSED', () => { 调用记录器
    StubConfigModule .getLogLevel .mockReturnValue("info");       
 
    验证密码(“任何内容”,[]);
 
    期望(mockLoggerModule.info )                                
 .toHaveBeenCalledWith(stringMatching( /       PASS/));            
  });
 
  test('具有调试日志级别且无规则,
        它使用 PASSED', () => { 调用记录器
    StubConfigModule .getLogLevel .mockReturnValue("调试");      
 
    验证密码(“任何内容”,[]);
 
    期望(mockLoggerModule.debug )                               
 .toHaveBeenCalledWith(stringMatching( /       PASS/));            
  });
});
jest.mock("./complicated-logger");                              
jest.mock("./configuration-service");                           
 
const { stringMatching } = expect;
const { verifyPassword } = require("./password-verifier");
const mockLoggerModule = require("./complicated-logger");       
const stubConfigModule = require("./configuration-service");    
 
describe("password verifier", () => {
  afterEach(jest.resetAllMocks);                                
 
  test('with info log level and no rules, 
          it calls the logger with PASSED', () => {
    stubConfigModule.getLogLevel.mockReturnValue("info");       
 
    verifyPassword("anything", []);
 
    expect(mockLoggerModule.info)                               
      .toHaveBeenCalledWith(stringMatching(/PASS/));            
  });
 
  test('with debug log level and no rules, 
        it calls the logger with PASSED', () => {
    stubConfigModule.getLogLevel.mockReturnValue("debug");      
 
    verifyPassword("anything", []);
 
    expect(mockLoggerModule.debug)                              
      .toHaveBeenCalledWith(stringMatching(/PASS/));            
  });
});

伪造模块

Faking the modules

获取模块的假实例

Getting the fake instances of the modules

告诉 Jest 在测试之间重置任何虚假模块行为

Telling Jest to reset any fake module behavior between tests

配置桩以返回假“信息”值。

Configuring the stub to return a fake “info” value.

断言模拟被正确调用

Asserting that the mock was called correctly

更改桩配置

Changing the stub config

如前所述对模拟记录器进行断言

Asserting on the mock logger as done previously

通过在这里使用 Jest,我节省了大量的打字时间,而且测试看起来仍然非常可读。

By using Jest here, I’ve saved myself a bunch of typing, and the tests still look pretty readable.

5.2.1 关于 Jest API 的一些注意事项

5.2.1 Some things to notice about Jest’s API

Jest 几乎在所有地方都使用“mock”这个词,无论我们是在桩还是嘲笑它们,这可能会有点令人困惑。如果它有“桩”一词别名为“模拟”以使内容更具可读性,那就太好了。

Jest uses the word “mock” almost everywhere, whether we’re stubbing things or mocking them, which can be a bit confusing. It’d be great if it had the word “stub” aliased to “mock” to make things more readable.

另外,由于 JavaScript“提升”的工作方式,伪造模块的行(通过jest.mock)需要位于文件的顶部。您可以在 Ashutosh Verma 的“理解 JavaScript 中的提升”文章中阅读更多相关信息:http://mng.bz/j11r

Also, due to the way JavaScript “hoisting” works, the lines faking the modules (via jest.mock) will need to be at the top of the file. You can read more about this in Ashutosh Verma’s “Understanding Hoisting in JavaScript” article here: http://mng.bz/j11r.

另请注意,Jest 还有许多其他 API 和功能,如果您有兴趣使用它,那么值得探索它们。请访问https://jestjs.io/以了解完整情况 - 这超出了本书的范围,本书主要是关于模式,而不是工具。

Also note that Jest has many other APIs and abilities, and its worth exploring them if you’re interested in using it. Head over to https://jestjs.io/ to get the full picture—it’s beyond the scope of this book, which is mostly about patterns, not tools.

其他一些框架,其中包括Sinon(https://sinonjs.org),也支持伪造模块。就隔离框架而言,Sinon 的使用非常愉快,但与 JavaScript 世界中的许多其他框架一样,也很像 Jest,它包含太多完成同一任务的方法,这通常会令人困惑。尽管如此,如果没有这些框架,手动伪造模块可能会非常烦人。

A few other frameworks, among them Sinon (https://sinonjs.org), also support faking modules. Sinon is quite pleasant to work with, as far as isolation frameworks go, but like many other frameworks in the JavaScript world, and much like Jest, it contains too many ways of accomplishing the same task, and that can often be confusing. Still, faking modules by hand can be quite annoying without these frameworks.

5.2.2 考虑抽象掉直接依赖关系

5.2.2 Consider abstracting away direct dependencies

关于 API 和其他类似 API 的好消息jest.mock是,它满足了开发人员的真实需求,这些开发人员一直在尝试测试具有不易更改的内置依赖项(即他们无法控制的代码)的模块。这个问题在遗留代码情况下非常普遍,我将在第 12 章中讨论。

The good news about the jest.mock API, and others like it, is that it meets a very real need for developers who are stuck trying to test modules that have baked-in dependencies that are not easily changeable (i.e., code they cannot control). This issue is very prevalent in legacy code situations, which I’ll discuss in chapter 12.

关于 API 的坏消息jest.mock是,它还允许我们模拟我们所控制的代码,并且这可能会从抽象出更简单、更短的内部 API 背后的真正依赖关系中受益。这种方法也称为洋葱架构六边形架构端口和适配器,对于我们代码的长期可维护性非常有用。您可以在 Alistair Cockburn 的文章“六角形架构”中阅读有关此类架构的更多信息,网址为https://alistair.cockburn.us/hexagonal-architecture/

The bad news about the jest.mock API is that it also allows us to mock the code that we do control and that might have benefited from abstracting away the real dependencies behind simpler, shorter, internal APIs. This approach, also known as onion architecture or hexagonal architecture or ports and adapters, is very useful for the long-term maintainability of our code. You can read more about this type of architecture in Alistair Cockburn’s article, “Hexagonal Architecture,” at https://alistair.cockburn.us/hexagonal-architecture/.

为什么直接依赖可能存在问题?通过直接使用这些 API,我们还被迫在测试中直接伪造模块 API,而不是它们的抽象。我们将这些直接 API 的设计与测试的实现结合起来,这意味着如果(或实际上当)这些 API 发生变化,我们还需要更改许多测试。

Why are direct dependencies potentially problematic? By using those APIs directly, we’re also forced into faking the module APIs directly in our tests instead of their abstractions. We’re gluing the design of those direct APIs to the implementation of the tests, which means that if (or really, when) those APIs change, we’ll also need to change many of our tests.

这是一个简单的例子。想象一下,您的代码依赖于一个著名的 JavaScript 日志框架(例如 Winston),并且在代码中的数百或数千个位置直接依赖于它。然后想象一下温斯顿发布了一个重大升级。随之而来的是很多痛苦,这些痛苦本可以在事情失控之前更早地解决。实现此目的的一种简单方法是对单个适配器文件进行简单抽象,该适配器文件是唯一保存对该记录器的引用的文件。该抽象可以公开我们可以控制的更简单的内部日志记录 API,因此我们可以防止代码出现大规模破坏。我将在第 12 章再次讨论这个主题。

Here’s a quick example. Imagine your code depends on a well-known JavaScript logging framework (such as Winston) and depends on it directly in hundreds or thousands of places in the code. Then imagine that Winston releases a breaking upgrade. Lots of pain will ensue, which could have been addressed much earlier, before things got out of hand. One simple way to accomplish this would be with a simple abstraction to a single adapter file, which is the only one holding a reference to that logger. That abstraction can expose a simpler, internal logging API that we do control, so we can prevent large-scale breakage across our code. I’ll return to this subject in chapter 12.

5.3 函数式动态模拟和桩

5.3 Functional dynamic mocks and stubs

我们讨论了模块化依赖关系,所以让我们转向伪造简单的函数。我们在前面的章节中已经做过很多次了,但我们总是手动完成。这对于桩来说非常有效,但是对于模拟来说它很快就会变得烦人。

We covered modular dependencies, so let’s turn to faking simple functions. We’ve done that plenty of times in the previous chapters, but we’ve always done it by hand. That works great for stubs, but for mocks it gets annoying fast.

下面的清单显示了我们之前使用的手动方法。

The following listing shows the manual approach we used before.

清单 5.3 手动模拟函数以验证它是否被调用

Listing 5.3 Manually mocking a function to verify it was called

test("给定记录器和传递场景", () => {
   letlogging  = "";                                         
  const mockLog = { info: (text) => (logged = text) };    
  const passVerify = makeVerifier([],mockLog);
 
  passVerify("任何输入");
 
  期望(记录)。toMatch(/通过/);                       
});
test("given logger and passing scenario", () => {
  let logged = "";                                       
  const mockLog = { info: (text) => (logged = text) };   
  const passVerify = makeVerifier([], mockLog);
 
  passVerify("any input");
 
  expect(logged).toMatch(/PASSED/);                      
});

声明一个自定义变量来保存传入的值

Declaring a custom variable to hold the value passed in

将传入的值保存到该变量中

Saving the passed-in value to that variable

断言变量的值

Asserting on the value of the variable

它有效——我们能够验证记录器函数是否被调用,但这需要大量的工作,并且可能会变得非常重复。输入像 Jest 这样的隔离框架。jest.fn()是摆脱此类代码的最简单方法。下面的清单显示了我们如何使用它。

It works—we’re able to verify that the logger function was called, but that’s a lot of work that can become very repetitive. Enter isolation frameworks like Jest. jest.fn() is the simplest way to get rid of such code. The following listing shows how we can use it.

清单 5.4 用于jest.fn()简单的函数模拟

Listing 5.4 Using jest.fn() for simple function mocks

test('给定记录器和传递场景', () => {
  const mockLog = { 信息: jest.fn() };
  const verify = makeVerifier([],mockLog);
 
  验证('任何输入');
 
  期望( mockLog.info )
     .toHaveBeenCalledWith(stringMatching (/PASS/) ) ;
});
test('given logger and passing scenario', () => {
  const mockLog = { info: jest.fn() };
  const verify = makeVerifier([], mockLog);
 
  verify('any input');
 
  expect(mockLog.info)
    .toHaveBeenCalledWith(stringMatching(/PASS/));
});

将此代码与前面的示例进行比较。虽然很微妙,但可以节省大量时间。这里我们用来jest.fn()获取 Jest 自动跟踪的函数,以便我们稍后可以通过toHaveBeenCalledWith(). 它小巧可爱,每当您需要跟踪对特定函数的调用时,它都能很好地工作。该函数是匹配器stringMatching的一个示例。匹配器通常被定义为一个实用函数,可以对发送到函数中的参数值进行断言。Jest 文档更自由地使用该术语,但您可以在 Jest 文档中找到匹配器的完整列表,网址为https://jestjs.io/docs/en/expect

Compare this code with the previous example. It’s subtle, but it saves plenty of time. Here we’re using jest.fn() to get back a function that is automatically tracked by Jest, so that we can query it later using Jest’s API via toHaveBeenCalledWith(). It’s small and cute, and it works well any time you need to track calls to a specific function. The stringMatching function is an example of a matcher. A matcher is usually defined as a utility function that can assert on the value of a parameter being sent into a function. The Jest docs use the term a bit more liberally, but you can find the full list of matchers in the Jest documentation at https://jestjs.io/docs/en/expect.

总而言之,jest.fn()它非常适合基于单函数的模拟和桩。让我们继续面对更加面向对象的挑战。

To summarize, jest.fn() works well for single-function-based mocks and stubs. Let’s move on to a more object-oriented challenge.

5.4 面向对象的动态模拟和桩

5.4 Object-oriented dynamic mocks and stubs

正如我们刚刚看到的,jest.fn()这是一个单函数伪造效用函数的示例。它在函数世界中运行良好,但当我们尝试在成熟的 API 接口或包含多个函数的类上使用它时,它会有点崩溃。

As we’ve just seen, jest.fn() is an example of a single-function faking utility function. It works well in a functional world, but it breaks down a bit when we try to use it on full-blown API interfaces or classes that contain multiple functions.

5.4.1 使用松散类型框架

5.4.1 Using a loosely typed framework

我之前提到过,隔离框架有两类。首先,我们将使用第一种(松散类型,功能友好)。下面的清单是一个尝试解决IComplicatedLogger我们在上一章中看到的问题的示例。

I mentioned before that there are two categories of isolation frameworks. To start, we’ll use the first (loosely typed, function-friendly) kind. The following listing is an example of trying to tackle the IComplicatedLogger we looked at in the previous chapter.

清单 5.5IComplicatedLogger接口

Listing 5.5 The IComplicatedLogger interface

导出接口IComplicatedLogger {
    信息(文本:字符串,方法:字符串)
    调试(文本:字符串,方法:字符串)
    警告(文本:字符串,方法:字符串)
    错误(文本:字符串,方法:字符串)
}
export interface IComplicatedLogger {
    info(text: string, method: string)
    debug(text: string, method: string)
    warn(text: string, method: string)
    error(text: string, method: string)
}

为此接口创建手写桩或模拟可能非常耗时,因为您需要记住每个方法的参数,如下一个清单所示。

Creating a handwritten stub or mock for this interface may be very time consuming, because you’d need to remember the parameters on a per-method basis, as the next listing shows.

清单 5.6 创建大量样板代码的手写桩

Listing 5.6 Handwritten stubs creating lots of boilerplate code

描述(“使用长接口”,()=> {
  描述(“密码验证器”,()=> {
    类 FakeLogger 实现 IComplicatedLogger {
      调试文本=“”;
      调试方法=“”;
      错误文本=“”;
      错误方法 = ""; 
      信息文本=“”;
      信息方法 = ""; 
      警告文本=“”;
      警告方法 = "";
 
      调试(文本:字符串,方法:字符串){
        this.debugText = 文本;
        this.debugMethod = 方法;
      }
 
      错误(文本:字符串,方法:字符串){
        this.errorText = 文本;
        this.errorMethod = 方法;
      }
      ...
    }
 
 
    test("验证,w 记录器并通过,使用 PASS 调用记录器", () => {
      const mockLog = new FakeLogger();
      constverifier=newPasswordVerifier2([],mockLog);
 
      verifier.verify("任何东西");
 
      期望(mockLog.infoText).toMatch(/通过/);
    });
  });
});
describe("working with long interfaces", () => {
  describe("password verifier", () => {
    class FakeLogger implements IComplicatedLogger {
      debugText = "";
      debugMethod = "";
      errorText = "";
      errorMethod = "";
      infoText = "";
      infoMethod = "";
      warnText = "";
      warnMethod = "";
 
      debug(text: string, method: string) {
        this.debugText = text;
        this.debugMethod = method;
      }
 
      error(text: string, method: string) {
        this.errorText = text;
        this.errorMethod = method;
      }
      ...
    }
 
 
    test("verify, w logger & passing, calls logger with PASS", () => {
      const mockLog = new FakeLogger();
      const verifier = new PasswordVerifier2([], mockLog);
 
      verifier.verify("anything");
 
      expect(mockLog.infoText).toMatch(/PASSED/);
    });
  });
});

真是一团糟。这种手写的假数据不仅耗时且编写起来很麻烦,如果您希望它在测试中的某个位置返回特定值,或者模拟记录器上函数调用的错误,会发生什么情况?我们可以做到,但是代码很快就会变得丑陋。

What a mess. Not only is this handwritten fake time consuming and cumbersome to write, what happens if you want it to return a specific value somewhere in the test, or simulate an error from a function call on the logger? We can do it, but the code gets ugly fast.

使用隔离框架,执行此操作的代码变得琐碎、更具可读性并且更短。让我们用于jest.fn()相同的任务,看看我们最终会得到什么结果。

Using an isolation framework, the code for doing this becomes trivial, more readable, and much shorter. Let’s use jest.fn() for the same task and see where we end up.

清单 5.7 模拟各个接口函数jest.fn()

Listing 5.7 Mocking individual interface functions with jest.fn()

导入 stringMatching = jasmine.stringMatching;
 
描述(“使用长接口”,()=> {
  描述(“密码验证器”,()=> {
    test("验证,w 记录器并通过,使用 PASS 调用记录器", () => {
      const mockLog: IComplicatedLogger = {     
        信息:jest.fn(),                        
        警告:jest.fn(),                        
        调试:jest.fn(),                       
        错误:jest.fn(),                       
      };
 
      constverifier=newPasswordVerifier2([],mockLog);
      verifier.verify("任何东西");
 
      期望(mockLog.info)
        .toHaveBeenCalledWith(stringMatching(/PASS/));
    });
  });
});
import stringMatching = jasmine.stringMatching;
 
describe("working with long interfaces", () => {
  describe("password verifier", () => {
    test("verify, w logger & passing, calls logger with PASS", () => {
      const mockLog: IComplicatedLogger = {    
        info: jest.fn(),                       
        warn: jest.fn(),                       
        debug: jest.fn(),                      
        error: jest.fn(),                      
      };
 
      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");
 
      expect(mockLog.info)
        .toHaveBeenCalledWith(stringMatching(/PASS/));
    });
  });
});

使用 Jest 设置模拟

Setting up the mock using Jest

不是太寒酸。这里我们简单地概述了我们自己的对象,并将一个jest.fn()函数附加到界面中的每个函数上。这可以节省大量的输入,但有一个重要的警告:每当接口发生变化(例如添加一个函数)时,我们就必须返回到定义该对象的代码并添加该函数。对于普通 JavaScript,这不会是一个问题,但如果测试中的代码使用了我们在测试中未定义的函数,它仍然会造成一些复杂性。

Not too shabby. Here we simply outline our own object and attach a jest.fn() function to each of the functions in the interface. This saves a lot of typing, but it has one important caveat: whenever the interface changes (a function is added, for example), we’ll have to go back to the code that defines this object and add that function. With plain JavaScript, this would be less of an issue, but it can still create some complications if the code under test uses a function we didn’t define in the test.

无论如何,将此类伪对象的创建推送到工厂辅助方法中可能是明智的做法,以便创建仅存在于单个位置。

In any case, it might be wise to push the creation of such a fake object into a factory helper method, so that the creation only exists in a single place.

5.4.2 切换到类型友好的框架

5.4.2 Switching to a type-friendly framework

我们切换到第二类框架并尝试substitute.js(www.npmjs.com/package/@fluffy-spoon/substitute)。我们必须选择一个,我非常喜欢这个框架的 C# 版本,并在本书的前一版本中使用了它。

Let’s switch to the second category of frameworks and try substitute.js (www.npmjs.com/package/@fluffy-spoon/substitute). We have to choose one, and I like the C# version of this framework a lot and used it in the previous edition of this book.

使用 Replace.js(以及使用 TypeScript 的假设),我们可以编写如下代码。

With substitute.js (and the assumption of working with TypeScript), we can write code like the following.

清单 5.8 使用 Replacement.js 来伪造一个完整的接口

Listing 5.8 Using substitute.js to fake a full interface

从“@fluffy-spoon/substitute”导入{ Substitute,Arg } ;
 
描述(“使用长接口”,()=> {
  描述(“密码验证器”,()=> {
    test("验证,w 记录器并通过,调用记录器 w PASS", () => {
      const mockLog = Substitute.for<IComplicatedLogger>();         
 
      constverifier=newPasswordVerifier2([],mockLog);
      verifier.verify("任何东西");
 
      mockLog.received().info(                                       
        Arg.is((x) => x.includes("PASSED")),                         
        "验证"                                                     
      );
    });
  });
});
import { Substitute, Arg } from "@fluffy-spoon/substitute";
 
describe("working with long interfaces", () => {
  describe("password verifier", () => {
    test("verify, w logger & passing, calls logger w PASS", () => {
      const mockLog = Substitute.for<IComplicatedLogger>();         
 
      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify("anything");
 
      mockLog.received().info(                                      
        Arg.is((x) => x.includes("PASSED")),                        
        "verify"                                                    
      );
    });
  });
});

生成假对象

Generating the fake object

验证假对象被调用

Verifying the fake object was called

在前面的清单中,我们生成了假对象,这使我们无需关心我们正在测试的函数之外的任何函数,即使该对象的签名将来发生变化。然后,我们使用.received()另一个参数匹配器 来作为我们的验证机制,Arg.is这一次来自 replacement.js 的 API,它的工作方式就像 Jasmine 中的字符串匹配一样。这里的额外好处是,如果将新函数添加到对象的签名中,我们将不太可能需要更改测试,并且无需将这些函数添加到使用相同对象签名的任何测试中

In the preceding listing, we generate the fake object, which absolves us of caring about any functions other than the one we’re testing against, even if the object’s signature changes in the future. We then use .received() as our verification mechanism, as well as another argument matcher, Arg.is, this time from substitute.js’s API, which works just like string matches from Jasmine. The added benefit here is that if new functions are added to the object’s signature, we will be less likely to need to change the test, and there’s no need to add those functions to any tests that use the same object signature.

隔离框架和 Arrange-Act-Assert 模式

Isolation frameworks and the Arrange-Act-Assert pattern

请注意,您使用隔离框架的方式与我们在第 1 章中讨论的 Arrange-Act-Assert 结构非常匹配。您首先安排一个假对象,对您正在测试的对象进行操作,然后断言测试结束时的一些事情。

Notice that the way you use the isolation framework matches nicely with the Arrange-Act-Assert structure, which we discussed in chapter 1. You start by arranging a fake object, you act on the thing you’re testing, and then you assert on something at the end of the test.

但事情并不总是那么容易。在过去(2006 年左右),大多数开源隔离框架不支持 Arrange-Act-Assert 的想法,而是使用称为 Record-Replay 的概念(我们谈论的是 Java 和 C#)。记录重放是一种令人讨厌的机制,您必须告诉隔离 API 它的假对象处于记录模式,然后您必须按照您期望从生产代码中调用的方式调用该对象上的方法。然后,您必须告诉隔离 API 切换到重播模式,只有这样您才能将假对象发送到生产代码的核心。可以在 Baeldung 网站www.baeldung.com/easymock上查看示例。

It wasn’t always this easy, though. In the olden days (around 2006), most of the open source isolation frameworks didn’t support the idea of Arrange-Act-Assert and instead used a concept called Record-Replay (we’re talking about Java and C#). Record-Replay was a nasty mechanism where you’d have to tell the isolation API that its fake object was in record mode, and then you’d have to call the methods on that object as you expected them to be called from production code. Then you’d have to tell the isolation API to switch into replay mode, and only then could you send your fake object into the heart of your production code. An example can be seen on the Baeldung site at www.baeldung.com/easymock.

与今天使用更具可读性的 Arrange-Act-Assert 模型编写测试的能力相比,这场悲剧使许多开发人员花费了数百万个小时来进行艰苦的测试阅读,以准确找出测试失败的位置。

Compared to today’s ability to write tests that use the far more readable Arrange-Act-Assert model, this tragedy cost many developers millions of combined hours in painstaking test reading to figure out exactly where tests failed.

如果您有本书的第一版,您可以在我展示 Rhino Mocks 时看到 Record-Replay 的示例(最初具有相同的设计)。

If you have the first edition of this book, you can see an example of Record-Replay when I showed Rhino Mocks (which initially had the same design).

好吧,那是嘲笑。桩呢?

OK, that was mocks. What about stubs?

5.5 动态桩行为

5.5 Stubbing behavior dynamically

Jest 有一个非常简单的 API,用于模拟模块化和功能依赖项的返回值:mockReturnValue()mockReturnValueOnce()

Jest has a very simple API for simulating return values for modular and functional dependencies: mockReturnValue() and mockReturnValueOnce().

清单 5.9 使用以下命令从伪函数中桩一个值jest.fn()

Listing 5.9 Stubbing a value from a fake function with jest.fn()

test("假相同的返回值", () => {
  const StubFunc = jest.fn()
    .mockReturnValue("abc");
 
  //值保持不变
  期望(stubFunc())。toBe(“abc”);
  期望(stubFunc())。toBe(“abc”);
  期望(stubFunc())。toBe(“abc”);
});
 
test("假多个返回值", () => {
  const StubFunc = jest.fn()
    .mockReturnValueOnce("a") 
    .mockReturnValueOnce("b") 
    .mockReturnValueOnce("c");
 
  //值保持不变
  期望(stubFunc())。toBe(“a”);
  期望(stubFunc())。toBe(“b”);
  期望(stubFunc())。toBe(“c”);
  期望(stubFunc())。toBe(未定义);
});
test("fake same return values", () => {
  const stubFunc = jest.fn()
    .mockReturnValue("abc");
 
  //value remains the same
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
  expect(stubFunc()).toBe("abc");
});
 
test("fake multiple return values", () => {
  const stubFunc = jest.fn()
    .mockReturnValueOnce("a")
    .mockReturnValueOnce("b")
    .mockReturnValueOnce("c");
 
  //value remains the same
  expect(stubFunc()).toBe("a");
  expect(stubFunc()).toBe("b");
  expect(stubFunc()).toBe("c");
  expect(stubFunc()).toBe(undefined);
});

请注意,在第一个测试中,我们在测试期间设置了一个永久返回值。如果可以使用的话,这是我编写测试的首选方法,因为它使测试易于阅读和维护。如果我们确实需要模拟多个值,我们可以使用mockReturnValueOnce.

Notice that, in the first test, we’re setting a permanent return value for the duration of the test. This is my preferred method of writing tests if I can use it, because it makes the tests simple to read and maintain. If we do need to simulate multiple values, we can use mockReturnValueOnce.

如果您需要模拟错误或做任何更复杂的事情,您可以使用mockImplementation()and mockImplementationOnce()

If you need to simulate an error or do anything more complicated, you can use mockImplementation() and mockImplementationOnce():

你的桩。模拟实现(() => {
  抛出新的错误();
});
yourStub.mockImplementation(() => {
  throw new Error();
});

5.5.1 带有模拟和桩的面向对象示例

5.5.1 An object-oriented example with a mock and a stub

让我们在密码验证器方程中添加另一个成分。

Let’s add another ingredient into our Password Verifier equation.

  • 假设密码验证程序在软件更新时的特殊维护时段内不处于活动状态。

  • Let’s say that the Password Verifier is not active during a special maintenance window, when software is being updated.

  • 当维护窗口处于活动状态时,调用verify()验证器将导致其调用logger.info()“正在维护”。

  • When a maintenance window is active, calling verify() on the verifier will cause it to call logger.info() with “under maintenance.”

  • 否则,它将logger.info()以“通过”或“失败”结果进行调用。

  • Otherwise it will call logger.info() with a “passed” or “failed” result.

为此(并且为了展示面向对象的设计决策),我们将引入一个MaintenanceWindow接口,该接口将被注入到密码验证器的构造函数中,如图 5.3 所示。

For this purpose (and for the purpose of showing an object-oriented design decision), we’ll introduce a MaintenanceWindow interface that will be injected into the constructor of our Password Verifier, as illustrated in figure 5.3.

05-03



图 5.3 使用MaintenanceWindow界面

Figure 5.3 Using the MaintenanceWindow interface

以下清单显示了使用新依赖项的密码验证程序的代码。

The following listing shows the code for the Password Verifier using the new dependency.

清单 5.10 带有MaintenanceWindow依赖项的密码验证器

Listing 5.10 Password Verifier with a MaintenanceWindow dependency

导出类PasswordVerifier3 {
  私有_规则:任何[];
  私人_logger:IComplicatedLogger;
  私有_maintenanceWindow:维护窗口;
 
  构造函数(
    规则:任意[],
    记录器:IComplicatedLogger,
    维护窗口:维护窗口
  ){
    this._rules = 规则;
    this._logger = 记录器;
    这。_维护窗口 = 维护窗口;
  }
 
  验证(输入:字符串):布尔值{
    if (this._maintenanceWindow.isUnderMaintenance ( )) { 
      this. _ logger.info("维护中", "验证"); 
      返回假;
    }
    const 失败 = this._rules
      .map((规则) => 规则(输入))
      .filter((结果) => 结果 === false);
 
    if (失败.length === 0) {
      this._logger.info("通过", "验证");
      返回真;
    }
    this._logger.info("失败", "验证");
    返回假;
  }
}
export class PasswordVerifier3 {
  private _rules: any[];
  private _logger: IComplicatedLogger;
  private _maintenanceWindow: MaintenanceWindow;
 
  constructor(
    rules: any[],
    logger: IComplicatedLogger,
    maintenanceWindow: MaintenanceWindow
  ) {
    this._rules = rules;
    this._logger = logger;
    this._maintenanceWindow = maintenanceWindow;
  }
 
  verify(input: string): boolean {
    if (this._maintenanceWindow.isUnderMaintenance()) {
      this._logger.info("Under Maintenance", "verify");
      return false;
    }
    const failed = this._rules
      .map((rule) => rule(input))
      .filter((result) => result === false);
 
    if (failed.length === 0) {
      this._logger.info("PASSED", "verify");
      return true;
    }
    this._logger.info("FAIL", "verify");
    return false;
  }
}

MaintenanceWindow接口作为构造函数参数注入(即使用构造函数注入),用于确定在哪里执行或不执行密码验证并向记录器发送正确的消息。

The MaintenanceWindow interface is injected as a constructor parameter (i.e., using constructor injection), and it’s used to determine where to execute or not execute the password verification and send the proper message to the logger.

5.5.2 使用 Replacement.js 进行桩和模拟

5.5.2 Stubs and mocks with substitute.js

现在我们将使用 Replace.js 而不是 Jest 来创建MaintenanceWindow接口的桩和接口的模拟IComplicatedLogger。图 5.4 说明了这一点。

Now we’ll use substitute.js instead of Jest to create a stub of the MaintenanceWindow interface and a mock of the IComplicatedLogger interface. Figure 5.4 illustrates this.

05-04



图 5.4MaintenanceWindow依赖关系

Figure 5.4 A MaintenanceWindow dependency

使用 Replace.js 创建桩和模拟的工作方式相同:我们使用该Substitute.for<T>函数。我们可以使用该.returns函数配置桩并使用该函数验证.received模拟。这两个都是从返回的假对象的一部分Substitute.for<T>().

Creating stubs and mocks with substitute.js works the same way: we use the Substitute.for<T> function. We can configure stubs with the .returns function and verify mocks with the .received function. Both of these are part of the fake object that is returned from Substitute.for<T>().

桩创建和配置如下所示:

Here’s what stub creation and configuration looks like:

const StubMaintWindow = Substitute.for<MaintenanceWindow> ();
StubMaintWindow.isUnderMaintenance() .returns(true);
const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(true);

模拟创建和验证如下所示:

Mock creation and verification looks like this:

const mockLog = Substitute.for<IComplicatedLogger>();
。。。
/// 稍后在测试结束时...
mockLog .received() .info("维护中", "验证");
const mockLog = Substitute.for<IComplicatedLogger>();
. . .
/// later down in the end of the test...
mockLog.received().info("Under Maintenance", "verify");

以下清单显示了使用模拟和桩的几个测试的完整代码。

The following listing shows the full code for a couple of tests that use a mock and a stub.

清单 5.11 使用 Replace.js 测试密码验证器

Listing 5.11 Testing Password Verifier with substitute.js

从“@fluffy-spoon/substitute”导入{替代};
 
const makeVerifierWithNoRules = (log, maint) =>
  新的PasswordVerifier3([], log, maint);
 
描述(“使用替代部分 2”,()=> {
  test("验证,在维护期间,调用记录器", () => {
    const StubMaintWindow = Substitute.for<MaintenanceWindow>() ;
    StubMaintWindow.isUnderMaintenance() .returns (true);
    const mockLog = Substitute.for<IComplicatedLogger>() ;
    const verifier = makeVerifierWithNoRules(mockLog,stubMaintWindow);
 
    verifier.verify("任何东西");
 
    mockLog .received() .info("维护中", "验证");
  });
 
  test("验证,外部维护,调用记录器", () => {
    const StubMaintWindow = Substitute.for<MaintenanceWindow>();
    StubMaintWindow.isUnderMaintenance().returns(false);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog,stubMaintWindow);
 
    verifier.verify("任何东西");
 
    mockLog.received().info("通过", "验证");
  });
});
import { Substitute } from "@fluffy-spoon/substitute";
 
const makeVerifierWithNoRules = (log, maint) =>
  new PasswordVerifier3([], log, maint);
 
describe("working with substitute part 2", () => {
  test("verify, during maintanance, calls logger", () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(true);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);
 
    verifier.verify("anything");
 
    mockLog.received().info("Under Maintenance", "verify");
  });
 
  test("verify, outside maintanance, calls logger", () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(false);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);
 
    verifier.verify("anything");
 
    mockLog.received().info("PASSED", "verify");
  });
});

我们可以使用动态创建的对象成功且相对容易地模拟测试中的值。我鼓励您研究您想要使用的隔离框架的风格。我在本书中只使用了 replacement.js 作为示例。它不是唯一的框架。

We can successfully and relatively easily simulate values in our tests with dynamically created objects. I encourage you to research the flavor of an isolation framework you’d like to use. I’ve only used substitute.js as an example in this book. It’s not the only framework out there.

该测试不需要手写假文,但请注意,它已经开始影响测试读者的可读性。功能性设计通常比这要简洁得多。在面向对象的环境中,有时这是一种不可避免的罪恶。然而,当我们重构代码时,我们可以轻松地将各种帮助器、模拟和桩的创建重构为帮助器函数,从而使测试变得更简单、更短,易于阅读。本书第 3 部分对此有更多介绍。

This test requires no handwritten fakes, but notice that it’s already starting to take a toll on the readability for the test reader. Functional designs are usually much slimmer than this. In an object-oriented setting, sometimes this is a necessary evil. However, we could easily refactor the creation of various helpers, mocks, and stubs to helper functions as we refactor our code, so that the test can be simpler and shorter to read. More on that in part 3 of this book.

5.6 隔离框架的优点和陷阱

5.6 Advantages and traps of isolation frameworks

根据本章介绍的内容,我们已经看到使用隔离框架的明显优势:

Based on what we’ve covered in this chapter, we’ve seen distinct advantages to using isolation frameworks:

  • 更容易的模块化伪造——如果没有一些样板代码,模块依赖关系可能很难解决,而隔离框架可以帮助我们消除这些样板代码。正如前面所解释的,这一点也可以算作负面因素,因为它鼓励我们将代码与第三方实现强耦合。

  • Easier modular faking—Module dependencies can be hard to get around without some boilerplate code, which isolation frameworks help us eliminate. This point can also be counted as a negative, as explained earlier, because it encourages us to have code strongly coupled to third-party implementations.

  • 更轻松地模拟值或错误- 在复杂的界面上手动编写模拟可能很困难。框架有很大帮助。

  • Easier simulation of values or errors—Writing mocks manually can be difficult across a complicated interface. Frameworks help a lot.

  • 更容易创建假冒——隔离框架可用于更轻松地创建模拟和桩。

  • Easier fake creation—Isolation frameworks can be used to create both mocks and stubs more easily.

尽管使用隔离框架有很多优点,但也存在可能的危险。现在我们来谈谈一些需要注意的事情。

Although there are many advantages to using isolation frameworks, there are also possible dangers. Let’s now talk about a few things to watch out for.

5.6.1 大多数时候你不需要模拟对象

5.6.1 You don’t need mock objects most of the time

隔离框架导致你陷入的最大陷阱是让你很容易伪造任何东西,并鼓励你首先认为你需要模拟对象。我并不是说您不需要桩,但模拟对象不应该成为大多数单元测试的标准操作过程。请记住,工作单元可以具有三种不同类型的退出点:返回值、状态更改和调用第三方依赖项。只有其中一种类型可以从测试中的模拟对象中受益。其他人则不然。

The biggest trap that isolation frameworks lead you into is making it easy to fake anything, and encouraging you to think you need mock objects in the first place. I’m not saying you won’t need stubs, but mock objects shouldn’t be the standard operating procedure for most unit tests. Remember that a unit of work can have three different types of exit points: return values, state change, and calling a third-party dependency. Only one of these types can benefit from a mock object in your test. The others don’t.

我发现,在我自己的测试中,模拟对象大约出现在 2%-5% 的测试中。其余的测试通常是返回值或基于状态的测试。对于功能设计,模拟对象的数量应该接近于零,除了一些极端情况。

I find that, in my own tests, mock objects are present in perhaps 2%-5% of my tests. The rest of the tests are usually return-value or state-based tests. For functional designs, the number of mock objects should be near zero, except for some corner cases.

如果您发现自己定义了一个测试并验证了一个对象或函数被调用,请仔细考虑是否可以在没有模拟对象的情况下证明相同的功能,而是通过验证返回值或整个工作单元的行为变化从外部(例如,验证函数是否抛出异常,而之前没有抛出异常)。Vladimir Khorikov 的单元测试原理、实践和模式(Manning,2020 年)的第 6 章详细描述了如何将基于交互的测试重构为更简单、更可靠的测试,以检查返回值。

If you find yourself defining a test and verifying that an object or function was called, think carefully whether you can prove the same functionality without a mock object, but instead by verifying a return value or a change in the behavior of the overall unit of work from the outside (for example, verifying that a function throws an exception when it didn’t before). Chapter 6 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020) contains a detailed description of how to refactor interaction-based tests into simpler, more reliable tests that check a return value instead.

5.6.2 不可读的测试代码

5.6.2 Unreadable test code

在测试中使用模拟会使测试的可读性降低一些,但仍然具有足够的可读性,以便局外人可以查看它并了解发生了什么。在单个测试中进行许多模拟或许多期望可能会破坏测试的可读性,因此很难维护,甚至很难理解正在测试的内容。

Using a mock in a test makes the test a little less readable, but still readable enough that an outsider can look at it and understand what’s going on. Having many mocks, or many expectations, in a single test can ruin the readability of the test so it’s hard to maintain, or even to understand what’s being tested.

如果您发现您的测试变得不可读或难以遵循,请考虑删除一些模拟或一些模拟期望,或者将测试分成几个更易读的较小测试。

If you find that your test becomes unreadable or hard to follow, consider removing some mocks or some mock expectations, or separating the test into several smaller tests that are more readable.

5.6.3 验证错误的事情

5.6.3 Verifying the wrong things

模拟对象允许您验证接口上是否调用了方法或调用了函数,但这并不一定意味着您正在测试正确的东西。许多刚刚接触测试的人最终只是因为他们可以验证事物,而不是因为它有意义。示例可能包括以下内容:

Mock objects allow you to verify that methods were called on your interfaces or that functions were called, but that doesn’t necessarily mean that you’re testing the right thing. A lot of people new to tests end up verifying things just because they can, not because it makes sense. Examples may include the following:

  • 验证内部函数调用另一个内部函数(不是退出点)。

  • Verifying that an internal function calls another internal function (not an exit point).

  • 验证是否调用了桩(不应验证传入的依赖项;这是过度规范的反模式,我们将在第 5.6.5 节中讨论)。

  • Verifying that a stub was called (an incoming dependency should not be verified; it’s the overspecification antipattern, as we’ll discuss in section 5.6.5).

  • 验证某些内容是否被调用只是因为有人告诉您编写测试,而您不确定真正应该测试什么。(这是验证您是否正确理解要求的好时机。)

  • Verifying that something was called simply because someone told you to write a test, and you’re not sure what should really be tested. (This is a good time to verify that you’re understanding the requirements correctly.)

5.6.4 每个测试有多个模拟

5.6.4 Having more than one mock per test

每次测试仅测试一个问题被认为是一种良好的做法。测试多个关注点可能会导致维护测试的混乱和问题。在测试中拥有两个模拟与测试同一工作单元的多个最终结果(多个退出点)相同。

It’s considered good practice to test only one concern per test. Testing more than one concern can lead to confusion and problems maintaining the test. Having two mocks in a test is the same as testing several end results of the same unit of work (multiple exit points).

对于每个出口点,考虑编写一个单独的测试,因为它可以被视为一个单独的需求。当您只测试一个问题时,您的测试名称也可能会变得更加集中和可读。如果您无法命名您的测试,因为它做了太多事情并且名称变得非常通用(例如“XWorksOK”),那么是时候将其分成多个测试了。

For each exit point, consider writing a separate test, as it could be considered a separate requirement. Chances are that your test names will also become more focused and readable when you only test one concern. If you can’t name your test because it does too many things and the name becomes very generic (e.g., “XWorksOK”), it’s time to separate it into more than one test.

5.6.5 过度指定测试

5.6.5 Overspecifying the tests

如果您的测试有太多期望(x.received().X()x.received().Y()等),它可能会变得非常脆弱,即使整体功能仍然有效,但生产代码的最轻微更改也会中断。测试交互是一把双刃剑:测试太多,你就会开始忽视大局——整体功能;测试太少,您就会错过工作单元之间的重要交互。

If your test has too many expectations (x.received().X(), x.received().Y(), and so on), it may become very fragile, breaking on the slightest of production code changes, even though the overall functionality still works. Testing interactions is a double-edged sword: test them too much, and you start to lose sight of the big picture—the overall functionality; test them too little, and you’ll miss the important interactions between units of work.

以下是一些平衡这种影响的方法:

Here are some ways to balance this effect:

  • 尽可能使用桩而不是模拟——如果超过 5% 的测试使用模拟对象,那么您可能做得太过分了。桩可以无处不在。嘲笑,没那么多。您一次只需测试一种场景。模拟越多,测试结束时进行的验证就越多,但通常只有一项是重要的。其余的将是针对当前测试场景的噪音。

  • Use stubs instead of mocks when you can—If more than 5% of your tests use mock objects, you might be overdoing it. Stubs can be everywhere. Mocks, not so much. You only need to test one scenario at a time. The more mocks you have, the more verifications will take place at the end of the test, but usually only one will be the important one. The rest will be noise against the current test scenario.

  • 尽可能避免使用桩作为模拟- 仅使用桩将模拟值伪造到被测工作单元中或抛出异常。不要验证是否在桩上调用了方法。

  • Avoid using stubs as mocks if possible—Use a stub only for faking simulated values into the unit of work under test or to throw exceptions. Don’t verify that methods were called on stubs.

概括

Summary

  • 隔离或模拟框架允许您以对象或函数形式动态创建、配置和验证模拟和桩。与手写的伪造相比,隔离框架节省了大量时间,尤其是在模块化依赖情况下。

  • Isolation, or mocking, frameworks allow you to dynamically create, configure, and verify mocks and stubs, either in object or function form. Isolation frameworks save a lot of time compared to handwritten fakes, especially in modular dependency situations.

  • 隔离框架有两种类型:松散类​​型(例如 Jest 和 Sinon)和强类型(例如stitute.js)。松散类型的框架需要较少的样板文件,并且适合函数式代码;强类型框架在处理类和接口时非常有用。

  • There are two flavors of isolation frameworks: loosely typed (such as Jest and Sinon) and strongly typed (such as substitute.js). Loosely typed frameworks require less boilerplate and are good for functional-style code; strongly typed frameworks are useful when dealing with classes and interfaces.

  • 隔离框架可以替换整个模块,但尝试抽象出直接依赖项并伪造这些抽象。这将帮助您减少模块 API 更改时所需的重构量。

  • Isolation frameworks can replace whole modules, but try to abstract away direct dependencies and fake those abstractions instead. This will help you reduce the amount of refactoring needed when the module’s API changes.

  • 尽可能倾向于返回值或基于状态的测试而不是交互测试非常重要,以便您的测试尽可能少地假设内部实现细节。

  • It's important to lean toward return-value or state-based testing as opposed to interaction testing whenever you can, so that your tests assume as little as possible about internal implementation details.

  • 仅当没有其他方法来测试实现时才应使用模拟,因为如果您不小心,它们最终会导致更难以维护的测试

  • Mocks should be used only when there’s no other way to test the implementation, because they eventually lead to tests that are harder to maintain if you’re not careful.

  • 根据您正在使用的代码库选择使用隔离框架的方式。在遗留项目中,您可能需要伪造整个模块,因为这可能是向此类项目添加测试的唯一方法。在新建项目中,尝试在第三方模块之上引入适当的抽象。这一切都在于为工作选择正确的工具,因此在考虑如何解决测试中的特定问题时一定要着眼于大局。

  • Choose the way you work with isolation frameworks based on the codebase you are working on. In legacy projects, you may need to fake whole modules, as it might be the only way to add tests to such projects. In greenfield projects, try to introduce proper abstractions on top of third-party modules. It’s all about picking the right tool for the job, so be sure to look at the big picture when considering how to approach a specific problem in testing.

6 单元测试异步代码

6 Unit testing asynchronous code

本章涵盖

This chapter covers

  • 异步、done()等待
  • Async, done(), and awaits
  • 异步的集成和单元测试级别
  • Integration and unit test levels for async
  • 提取入口点模式
  • The Extract Entry Point pattern
  • 提取适配器模式
  • The Extract Adapter pattern
  • 桩、推进和重置计时器
  • Stubbing, advancing, and resetting timers

当我们处理常规同步代码时,等待操作完成是隐式的。我们并不担心这个问题,我们也没有想太多。然而,在处理异步代码时,等待操作完成成为我们控制下的显式活动。异步性使得代码以及该代码的测试可能变得更加棘手,因为我们必须明确等待操作完成

When we’re dealing with regular synchronous code, waiting for actions to finish is implicit. We don’t worry about it, and we don’t really think about it too much. When dealing with asynchronous code, however, waiting for actions to finish becomes an explicit activity that is under our control. Asynchronicity makes code, and the tests for that code, potentially trickier because we have to be explicit about waiting for actions to complete.

让我们从一个简单的获取示例开始来说明这个问题。

Let’s start with a simple fetching example to illustrate the issue.

6.1 处理异步数据获取

6.1 Dealing with async data fetching

假设我们有一个模块可以检查我们的网站 example.com 是否处于活动状态。它通过从主 URL 获取上下文并检查特定单词“说明性”来确定网站是否正常运行来实现此目的。我们将研究此功能的两种不同且非常简单的实现。第一个使用callback机制,第二个使用async/await机制

Let’s say we have a module that checks whether our website at example.com is alive. It does this by fetching the context from the main URL and checking for a specific word, “illustrative,” to determine if the website is up. We’ll look at two different and very simple implementations of this functionality. The first uses a callback mechanism, and the second uses an async/await mechanism.

图 6.1 说明了我们的目的的入口点和出口点。请注意,回调箭头的指向不同,以便更明显地表明它是不同类型的退出点。

Figure 6.1 illustrates their entry and exit points for our purposes. Note that the callback arrow is pointed differently, to make it more obvious that it’s a different type of exit point.

06-01



图 6.1IsWebsiteAlive()回调与async/await版本

Figure 6.1 IsWebsiteAlive() callback vs. the async/await version

初始代码如下面的清单所示。我们用来node-fetch获取 URL 的内容。

The initial code is shown in the following listing. We’re using node-fetch to get the URL’s content.

清单 6.1IsWebsiteAlive()回调和等待版本

Listing 6.1 IsWebsiteAlive() callback and await versions

//回调版本
const fetch = require("节点获取");
const isWebsiteAliveWithCallback = (回调) => {
  const 网站 = "http://example.com";
  获取(网站)
    .then((响应) => {
      如果(!response.ok){
        //我们如何模拟这个网络问题?
        抛出错误(response.statusText);             
      }
      返回响应;
    })
    .then((响应) => 响应.text())
    .then((文本) => {
      if (text.includes("说明性")) {
        回调({成功:true,状态:“确定”});
      } 别的 {
        //我们如何测试这条路径?
        回调({ 成功: false, 状态: "文本丢失" });
      }
    })
    .catch((错误) => {
      //我们如何测试这个退出点?
      回调({ 成功: false, 状态: 错误 });
    });
};
 
// 等待版本
const isWebsiteAliveWithAsyncAwait = async () => {
  尝试 {
    const resp = wait fetch("http://example.com");
    如果(!resp.ok){
      //我们如何模拟一个不正常的响应?
      抛出 resp.statusText;                        
    }
    const text = wait resp.text();
    const Include = text.includes("说明性");
    如果(包含){
      返回{成功:true,状态:“确定”};
    }
    // 我们如何模拟不同的网站内容?
    抛出“文本缺失”;
  } 捕获(错误){
    返回 { 成功:错误,状态:错误 };         
  }
};
//Callback version
const fetch = require("node-fetch");
const isWebsiteAliveWithCallback = (callback) => {
  const website = "http://example.com";
  fetch(website)
    .then((response) => {
      if (!response.ok) {
        //how can we simulate this network issue?
        throw Error(response.statusText);             
      }
      return response;
    })
    .then((response) => response.text())
    .then((text) => {
      if (text.includes("illustrative")) {
        callback({ success: true, status: "ok" });
      } else {
        //how can we test this path?
        callback({ success: false, status: "text missing" });
      }
    })
    .catch((err) => {
      //how can we test this exit point?
      callback({ success: false, status: err });
    });
};
 
// Await version
const isWebsiteAliveWithAsyncAwait = async () => {
  try {
    const resp = await fetch("http://example.com");
    if (!resp.ok) {
      //how can we simulate a non ok response?
      throw resp.statusText;                        
    }
    const text = await resp.text();
    const included = text.includes("illustrative");
    if (included) {
      return { success: true, status: "ok" };
    }
    // how can we simulate different website content?
    throw "text missing";
  } catch (err) {
    return { success: false, status: err };         
  }
};

抛出自定义错误来处理代码中的问题

Throwing a custom error to handle problems in our code

抛出自定义错误来处理我们代码中的问题

Throwing a custom error to handle problems in our code

将错误包装到响应中

Wrapping the error into a response

注意在前面的代码中,我假设您知道 Promise 在 JavaScript 中如何工作。如果您需要更多信息,我建议您阅读http://mng.bz/W11a上有关 Promise 的 Mozilla 文档。

Note In the preceding code, I’m assuming you know how promises work in JavaScript. If you need more information, I recommend reading the Mozilla documentation on promises at http://mng.bz/W11a.

在此示例中,我们将连接失败或网页上缺少文本引起的任何错误转换为回调或返回值,以向函数用户表示失败。

In this example, we are converting any errors from connectivity failures or missing text on the web page to either a callback or a return value to denote a failure to the user of our function.

6.1.1 集成测试的初步尝试

6.1.1 An initial attempt with an integration test

由于清单 6.1 中的所有内容都是硬编码的,您将如何测试它?您的最初反应可能涉及编写集成测试。以下清单显示了我们如何为回调版本编写集成测试。

Since everything is hardcoded in listing 6.1, how would you test this? Your initial reaction might involve writing an integration test. The following listing shows how we could write an integration test for the callback version.

清单 6.2 初始集成测试

Listing 6.2 An initial integration test

测试(“网络需要(回调):正确的内容,true”,(完成)=> {
  样品。isWebsiteAliveWithCallback ((结果) => {
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
    完毕();
  });
});
test("NETWORK REQUIRED (callback): correct content, true", (done) => {
  samples.isWebsiteAliveWithCallback((result) => {
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
    done();
  });
});

为了测试退出点是回调函数的函数,我们向它传递我们自己的回调函数,我们可以在其中

To test a function whose exit point is a callback function, we pass it our own callback function in which we can

  • 检查传入值的正确性

  • Check the correctness of the passed-in values

  • 通过测试框架提供给我们的任何机制告诉测试运行者停止等待(在本例中,这就是函数done()

  • Tell the test runner to stop waiting through whatever mechanism is given to us by the test framework (in this case, that’s the done() function)

6.1.2 等待行为

6.1.2 Waiting for the act

因为我们使用回调作为退出点,所以我们的测试必须显式等待直到并行执行完成。并行执行可以在 JavaScript 事件循环上,也可以在单独的线程中,甚至在单独的进程中(如果您使用其他语言)。

Because we’re using callbacks as exit points, our test has to explicitly wait until the parallel execution completes. That parallel execution could be on the JavaScript event loop or it could be in a separate thread, or even in a separate process if you’re using another language.

在排列-行动-断言模式中,行动部分是我们需要等待的事情。大多数测试框架将允许我们使用特殊的辅助函数来做到这一点。在这种情况下,我们可以使用 Jest 提供的可选done回调来表明测试需要等待,直到我们显式调用done()。如果done()不调用,我们的测试将在默认的 5 秒后超时并失败(当然,这是可配置的)。

In the Arrange-Act-Assert pattern, the act part is the thing we need to wait out. Most test frameworks will allow us to do so with special helper functions. In this case, we can use the optional done callback that Jest provides to signal that the test needs to wait until we explicitly call done(). If done() isn’t called, our test will time out and fail after the default 5 seconds (which is configurable, of course).

Jest 还有其他测试异步代码的方法,我们将在本章后面介绍其中的几种方法。

Jest has other means for testing asynchronous code, a couple of which we’ll cover later in the chapter.

6.1.3 async/await 集成测试

6.1.3 Integration testing of async/await

async/版本怎么样await?从技术上讲,我们可以编写一个看起来几乎与前一个测试完全相同的测试,因为async/await只是承诺的语法糖。

What about the async/await version? We could technically write a test that looks almost exactly like the previous one, since async/await is just syntactic sugar over promises.

清单 6.3 带有回调的集成测试.then()

Listing 6.3 Integration test with callbacks and .then()

测试(“需要网络(等待):正确的内容,true”,(完成)=> {
  Samples.isWebsiteAliveWithAsyncAwait() .then( (结果) => {
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
    完毕(); 
  } ) ;
});
test("NETWORK REQUIRED (await): correct content, true", (done) => {
  samples.isWebsiteAliveWithAsyncAwait().then((result) => {
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
    done();
  });
});

然而,使用诸如done()和 之类的回调的测试then()的可读性远低于使用 Arrange-Act-Assert 模式的测试。好消息是,没有必要强迫自己使用回调来使我们的生活变得复杂。await我们也可以在测试中使用该语法。这将迫使我们将async关键字放在测试函数的前面,但是,总的来说,我们的测试变得更简单且更具可读性,正如您在此处看到的那样。

However, a test that uses callbacks such as done() and then() is much less readable than one using the Arrange-Act-Assert pattern. The good news is there’s no need to complicate our lives by forcing ourselves to use callbacks. We can use the await syntax in our test as well. This will force us to put the async keyword in front of the test function, but, overall, our test becomes simpler and more readable, as you can see here.

清单 6.4 集成测试async/await

Listing 6.4 Integration test with async/await

测试(“网络需要2(等待):正确的内容,true”,async()=> {
   const result =等待样本.isWebsiteAliveWithAsyncAwait();
  期望(结果.成功).toBe(true);
  期望(结果。状态)。toBe(“确定”);
});
test("NETWORK REQUIRED2 (await): correct content, true", async () => {
  const result = await samples.isWebsiteAliveWithAsyncAwait();
  expect(result.success).toBe(true);
  expect(result.status).toBe("ok");
});

拥有允许我们使用async/语法的异步代码将我们的测试几乎await变成了基于值的普通测试。入口点也是出口点,如图 6.1 所示。

Having asynchronous code that allows us to use the async/await syntax turns our test into almost a run-of-the-mill value-based test. The entry point is also the exit point, as we saw in figure 6.1.

尽管调用被简化了,但底层的调用仍然是异步的,这就是为什么我仍然称其为集成测试。此类测试有哪些注意事项?来!我们讨论一下。

Even though the call is simplified, the call is still asynchronous underneath, which is why I still call this an integration test. What are the caveats for this type of test? Let’s discuss.

6.1.4 集成测试的挑战

6.1.4 Challenges with integration tests

就集成测试而言,我们刚刚编写的测试并不可怕。它们相对较短且可读,但它们仍然受到任何集成测试的困扰:

The tests we’ve just written aren’t horrible as far as integration tests go. They’re relatively short and readable, but they still suffer from what any integration test suffers from:

  • 运行时间长——与单元测试相比,集成测试要慢几个数量级,有时需要几秒钟甚至几分钟。

  • Lengthy run time—Compared to unit tests, integration tests are orders of magnitude slower, sometimes taking seconds or even minutes.

  • Flaky——集成测试可能会呈现不一致的结果(根据运行位置不同的时间、不一致的失败或成功等)

  • Flaky—Integration tests can present inconsistent results (different timings based on where they run, inconsistent failures or successes, etc.)

  • 测试可能不相关的代码和环境条件——集成测试测试可能与我们关心的内容无关的多段代码。(在我们的例子中,是node-fetch库、网络条件、防火墙、外部网站功能等)

  • Tests possibly irrelevant code and environment conditions—Integration tests test multiple pieces of code that might be unrelated to what we care about. (In our case, it’s the node-fetch library, network conditions, firewall, external website functionality, etc.)

  • 更长时间的调查——当集成测试失败时,需要更多的时间进行调查和调试,因为失败的可能原因有很多。

  • Longer investigations—When an integration test fails, it requires more time for investigation and debugging because there are many possible reasons for a failure.

  • 模拟更难——用集成测试来模拟负面测试(模拟错误的网站内容、网站关闭、网络关闭等)比需要的更难

  • Simulation is harder—It is harder than it needs to be to simulate a negative test with an integration test (simulating wrong website content, website down, network down, etc.)

  • 更难信任结果——我们可能认为集成测试的失败是由于外部问题造成的,而实际上这是我们代码中的错误。我将在下一章更多地讨论信任。

  • Harder to trust results—We might believe the failure of an integration test is due to an external issue when in fact it’s a bug in our code. I’ll talk about trust more in the next chapter.

这是否意味着您不应该编写集成测试?不,我相信您绝对应该进行集成测试,但您不需要进行那么多集成测试来对您的代码有足够的信心。任何集成测试未涵盖的内容都应该由较低级别的测试涵盖,例如单元、API 或组件测试。我将在第 10 章详细讨论这个策略,该章重点讨论测试策略。

Does all this mean you shouldn’t write integration tests? No, I believe you should absolutely have integration tests, but you don’t need to have that many of them to get enough confidence in your code. Whatever integration tests don’t cover should be covered by lower-level tests, such as unit, API, or component tests. I’ll discuss this strategy at length in chapter 10, which focuses on testing strategies.

6.2 使我们的代码易于单元测试

6.2 Making our code unit-test friendly

我们如何通过单元测试来测试代码?我将向您展示一些我用来使代码更易于单元测试的模式(即,更轻松地注入或避免依赖项,并检查退出点):

How can we test the code with a unit test? I’ll show you some patterns that I use to make the code more unit testable (i.e., to more easily inject or avoid dependencies, and to check exit points):

  • 提取入口点模式——将生产代码中纯逻辑的部分提取到它们自己的函数中,并将这些函数视为我们测试的入口点

  • Extract Entry Point pattern—Extracting the parts of the production code that are pure logic into their own functions, and treating those functions as entry points for our tests

  • 提取适配器模式——提取本质上异步的东西并将其抽象化,以便我们可以用同步的东西替换它

  • Extract Adapter pattern—Extracting the thing that is inherently asynchronous and abstracting it away so that we can replace it with something that is synchronous

6.2.1 提取入口点

6.2.1 Extracting an entry point

在此模式中,我们采用特定的异步工作单元并将其分为两部分:

In this pattern, we take a specific unit of async work and split it into two pieces:

  • 异步部分(保持不变)。

  • The async part (which stays intact).

  • 异步执行完成时调用的回调。这些被提取为新函数,最终成为我们可以通过纯单元测试调用的纯逻辑工作单元的入口点。

  • The callbacks that are invoked when the async execution finishes. These are extracted as new functions, which eventually become entry points for a purely logical unit of work that we can invoke with pure unit tests.

图 6.2 描述了这个想法:在上图中,我们有一个工作单元,其中包含与内部处理异步结果并通过回调或 Promise 机制返回结果的逻辑混合的异步代码。在步骤 1 中,我们将逻辑提取到其自己的函数中,该函数仅包含异步工作的结果作为输入。在步骤 2 中,我们将这些函数外部化,以便我们可以将它们用作单元测试的入口点。

Figure 6.2 depicts this idea: In the before diagram, we have a single unit of work that contains asynchronous code mixed with logic that processes the async results internally and returns a result via a callback or promise mechanism. In step 1, we extract the logic into its own function (or functions) that contains only the results of the async work as inputs. In step 2, we externalize those functions so that we can use them as entry points for our unit tests.

06-02



图 6.2 将内部处理逻辑提取到单独的工作单元中有助于简化测试,因为我们能够同步验证新的工作单元,而无需涉及外部依赖项。

Figure 6.2 Extracting the internal processing logic into a separate unit of work helps simplify the tests, because we are able to verify the new unit of work synchronously and without involving external dependencies.

这为我们提供了测试异步回调的逻辑处理(并轻松模拟输入)的重要能力。同时,我们可以选择针对原始工作单元编写更高级别的集成测试,以确保异步编排也能正常工作。

This provides us with the important ability to test the logical processing of the async callbacks (and to simulate inputs easily). At the same time, we can choose to write a higher-level integration test against the original unit of work to gain confidence that the async orchestration works correctly as well.

如果我们只对所有场景进行集成测试,我们最终会陷入一个充满大量长时间运行且不稳定的测试的世界。在新世界中,我们能够让大多数测试快速且一致,并在顶部进行一小层集成测试,以确保所有编排在其间正常工作。这样我们就不会为了信心而牺牲速度和可维护性。

If we do integration tests only for all our scenarios, we would end up in a world of many long-running and flaky tests. In the new world, we’re able to have most of our tests be fast and consistent, and to have a small layer of integration tests on top to make sure all the orchestration works in between. This way we don’t sacrifice speed and maintainability for confidence.

提取工作单元的示例

Example of extracting a unit of work

让我们将此模式应用到代码中来自清单 6.1。图 6.3 显示了我们将遵循的步骤:

Let’s apply this pattern to the code from listing 6.1. Figure 6.3 shows the steps we’ll follow:

before状态包含嵌入到函数中的处理逻辑isWebsiteAlive()

The before state contains processing logic that is baked into the isWebsiteAlive() function.

我们将提取在获取结果边缘发生的任何逻辑代码,并将其放入两个单独的函数中:一个用于处理成功情况,另一个用于处理错误情况。

We’ll extract any logical code that happens at the edge of the fetch results and put it in two separate functions: one for handling the success case, and the other for the error case.

然后我们将外部化这两个函数,以便我们可以直接从单元测试中调用它们。

We’ll then externalize these two functions so that we can invoke them directly from unit tests.

06-03



图 6.3 从中提取成功和错误处理逻辑以isWebsiteAlive()分别测试该逻辑

Figure 6.3 Extracting the success and error-handling logic from isWebsiteAlive() to test that logic separately

以下清单显示了重构的代码。

The following listing shows the refactored code.

清单 6.5 提取入口点callback

Listing 6.5 Extracting entry points with callback

//入口点
const isWebsiteAlive = (回调) => {
  获取(“http://example.com”)
    .then(抛出无效响应)
    .then((resp) => resp.text())
    .then((文本) => {
     processFetchSuccess(文本, 回调);
    })
    .catch((错误) => {
     processFetchError(错误,回调);
    });
};
const throwOnInvalidResponse = (resp) => {
  如果(!resp.ok){
    抛出错误(resp.statusText);
  }
  返回响应;
};
 
//入口点
const processFetchSuccess = (text,callback) => {         
  if (text.includes("说明性")) { 
    callback({ success: true, status: "ok" }); 
  } else {
    回调({ 成功: false, 状态: "缺少文本" }); 
  } 
};
 
//入口点
const processFetchError = (err,callback) => {            ❶callback 
  ({ success: false, status: err }); 
};
//Entry Point
const isWebsiteAlive = (callback) => {
  fetch("http://example.com")
    .then(throwOnInvalidResponse)
    .then((resp) => resp.text())
    .then((text) => {
      processFetchSuccess(text, callback);
    })
    .catch((err) => {
      processFetchError(err, callback);
    });
};
const throwOnInvalidResponse = (resp) => {
  if (!resp.ok) {
    throw Error(resp.statusText);
  }
  return resp;
};
 
//Entry Point
const processFetchSuccess = (text, callback) => {        
  if (text.includes("illustrative")) {
    callback({ success: true, status: "ok" });
  } else {
    callback({ success: false, status: "missing text" });
  }
};
 
//Entry Point
const processFetchError = (err, callback) => {           
  callback({ success: false, status: err });
};

新的入口点(工作单元)

New entry points (units of work)

正如您所看到的,我们开始的原始单元现在有三个入口点,而不是我们开始时的单个入口点。新的入口点可用于单元测试,而原始入口点仍可用于集成测试,如图6.4所示。

As you can see, the original unit we started with now has three entry points instead of the single one we started with. The new entry points can be used for unit testing, while the original one can still be used for integration testing, as shown in figure 6.4.

06-04



图 6.4 提取两个新函数后引入的新入口点。现在可以使用更简单的单元测试来测试新功能,而不是重构之前所需的集成测试。

Figure 6.4 New entry points introduced after extracting the two new functions. The new functions can now be tested with simpler unit tests instead of the integration tests that were required before the refactoring.

我们仍然希望对原始入口点进行集成测试,但不超过其中一两个。任何其他场景都可以使用纯粹的逻辑入口点来快速、轻松地模拟。

We’d still want an integration test for the original entry point, but not more than one or two of those. Any other scenario can be simulated using the purely logical entry points, quickly and painlessly.

现在我们可以自由地编写调用新入口点的单元测试,如下所示。

Now we’re free to write unit tests that invoke the new entry points, like this.

清单 6.6 带有提取入口点的单元测试

Listing 6.6 Unit tests with extracted entry points

描述(“网站活动检查”,()=> {
  test("内容匹配,返回true", (done) => {
    samples.processFetchSuccess("说明性", (err, 结果) => {   
      期望(错误).toBeNull();
      期望(结果.成功).toBe(true);
      期望(结果。状态)。toBe(“确定”);
      完毕();
    });
  });
  test("网站内容不匹配,返回false", (done) => {
    Samples.processFetchSuccess("不良内容", (err, result) => {    
      Expect(err.message).toBe(“缺少文本”);
      完毕();
    });
  });
  test("当获取失败时,返回 false", (done) => {
   Samples.processFetchError("错误文本", (err,结果) => {         
      Expect(err.message).toBe(“错误文本”);
      完毕();
    });
  });
});
describe("Website alive checking", () => {
  test("content matches, returns true", (done) => {
    samples.processFetchSuccess("illustrative", (err, result) => {  
      expect(err).toBeNull();
      expect(result.success).toBe(true);
      expect(result.status).toBe("ok");
      done();
    });
  });
  test("website content does not match, returns false", (done) => {
    samples.processFetchSuccess("bad content", (err, result) => {   
      expect(err.message).toBe("missing text");
      done();
    });
  });
  test("When fetch fails, returns false", (done) => {
   samples.processFetchError("error text", (err,result) => {        
      expect(err.message).toBe("error text");
      done();
    });
  });
});

调用新的入口点

Invoking the new entry points

请注意,我们直接调用新的入口点,并且我们能够轻松模拟各种条件。在这些测试中没有什么是异步的,但我们仍然需要该done()函数,因为回调可能根本不会被调用,并且我们希望捕获它。

Notice that we are invoking the new entry points directly, and we’re able to simulate various conditions easily. Nothing is asynchronous in these tests, but we still need the done() function, since the callbacks might not be invoked at all, and we’ll want to catch that.

我们仍然需要至少一项集成测试,让我们确信异步编排在我们的入口点之间正常工作。这就是原始集成测试可以提供帮助的地方,但我们不再需要将所有测试场景编写为集成测试(第 10 章将详细介绍这一点)。

We still need at least one integration test that gives us confidence that the asynchronous orchestration works between our entry points. That’s where the original integration test can help, but we don’t need to write all our test scenarios as integration tests anymore (more on this in chapter 10).

使用await 提取入口点

Extracting an entry point with await

我们刚刚应用的相同模式可以很好地适用于标准async/await函数结构。图 6.5 说明了这种重构。

The same pattern we just applied can work well for standard async/await function structures. Figure 6.5 illustrates that refactoring.

06-05



图 6.5 提取入口点async/await

Figure 6.5 Extracting entry points with async/await

通过提供async/await语法,我们可以重新以线性方式编写代码,而无需使用回调参数。该isWebsiteAlive()函数开始看起来几乎与常规同步代码完全相同,仅在需要时返回值并抛出错误。

By providing the async/await syntax, we can go back to writing code in a linear fashion, without using callback arguments. The isWebsiteAlive() function starts looking almost exactly the same as regular synchronous code, only returning values and throwing errors when needed.

清单 6.7 显示了它在我们的生产代码中的样子。

Listing 6.7 shows how that looks in our production code.

async/await清单 6.7 用而不是回调编写的函数

Listing 6.7 The function written with async/await instead of callbacks

//入口点
const isWebsiteAlive = async () => {
  尝试 {
    const resp = wait fetch("http://example.com");
    throwIfResponseNotOK(resp);
    const text = wait resp.text();
    返回过程FetchContent(文本);
  } 捕获(错误){
    返回 processFetchError(err);
  }
};
 
const throwIfResponseNotOK = (resp) => {
  如果(!resp.ok){
    抛出 resp.statusText;
  }
};
 
//入口点
const processFetchContent = (文本) => {
  const Include = text.includes("说明性");
  如果(包含){
    返回{成功:true,状态:“确定”};          
  }
  return { success: false, status: "缺少文本" };
};
 
//入口点
const processFetchError = (err) => {
  返回 { 成功:错误,状态:错误 };             
};
//Entry Point
const isWebsiteAlive = async () => {
  try {
    const resp = await fetch("http://example.com");
    throwIfResponseNotOK(resp);
    const text = await resp.text();
    return processFetchContent(text);
  } catch (err) {
    return processFetchError(err);
  }
};
 
const throwIfResponseNotOK = (resp) => {
  if (!resp.ok) {
    throw resp.statusText;
  }
};
 
//Entry Point
const processFetchContent = (text) => {
  const included = text.includes("illustrative");
  if (included) {
    return { success: true, status: "ok" };          
  }
  return { success: false, status: "missing text" }; 
};
 
//Entry Point
const processFetchError = (err) => {
  return { success: false, status: err };            
};

返回一个值而不是调用回调

Returning a value instead of calling a callback

请注意,与回调示例不同,我们使用returnthrow来表示成功或失败。async这是使用/编写代码的常见模式await

Notice that, unlike the callback examples, we’re using return or throw to denote success or failure. This is a common pattern of writing code using async/await.

我们的测试也得到了简化,如下面的清单所示。

Our tests are simplified as well, as shown in the following listing.

清单 6.8 测试从中提取的入口点async/await

Listing 6.8 Testing entry points extracted from async/await

描述(“网站上线检查”,()=> {
  test("成功获取良好内容,返回 true", () => {
    const 结果 = Samples.processFetchContent("说明性");
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
  });
 
  test("如果内容错误,则获取成功,返回 false", () => {
    const result = Samples.processFetchContent("文本不在现场");
    期望(结果.成功).toBe(假);
    Expect(result.status).toBe("缺少文本");
  });
 
  测试(“获取失败时,抛出”,()=> {
    Expect(() => Samples.processFetchError("错误文本"))
      .toThrowError("错误文本");
  });
});
describe("website up check", () => {
  test("on fetch success with good content, returns true", () => {
    const result = samples.processFetchContent("illustrative");
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });
 
  test("on fetch success with bad content, returns false", () => {
    const result = samples.processFetchContent("text not on site");
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });
 
  test("on fetch fail, throws ", () => {
    expect(() => samples.processFetchError("error text"))
      .toThrowError("error text");
  });
});

再次注意,我们不需要添加任何类型的async/await相关关键字或明确等待执行,因为我们已经将工作的逻辑单元与使我们的生活变得更加复杂的异步部分分开。

Again, notice that we don’t need to add any kind of async/await-related keywords or to be explicit about waiting for execution, because we’ve separated the logical unit of work from the asynchronous pieces that make our lives more complicated.

6.2.2 提取适配器模式

6.2.2 The Extract Adapter pattern

提取适配器模式采取与先前模式相反的观点。我们查看异步代码段就像查看前面章节中讨论的任何依赖项一样,作为我们希望在测试中替换的内容以获得更多控制。我们不会将逻辑代码提取到自己的入口点集中,而是提取异步代码(我们的依赖项)并将其抽象到适配器下,稍后我们可以注入该适配器,就像任何其他依赖项一样。图 6.6 显示了这一点。

The Extract Adapter pattern takes the opposite view from the previous pattern. We look at the asynchronous piece of code just like we look at any dependency we’ve discussed in the previous chapters—as something we’d like to replace in our tests to gain more control. Instead of extracting the logical code into its own set of entry points, we’ll extract the asynchronous code (our dependency) and abstract it away under an adapter, which we can later inject, just like any other dependency. Figure 6.6 shows this.

06-06



图 6.6 提取依赖项并用适配器包装它可以帮助我们简化该依赖项并在测试中将其替换为伪造的依赖项。

Figure 6.6 Extracting a dependency and wrapping it with an adapter helps us simplify that dependency and replace it with a fake in tests.

为适配器创建一个特殊的接口也很常见,该接口可以根据依赖项使用者的需求进行简化。这种方法的另一个名称是接口隔离原则。在本例中,我们将创建一个network-adapter隐藏真正的获取功能并具有自己的自定义函数的模块,如图 6.7 所示。

It’s also common to create a special interface for the adapter that is simplified for the needs of the consumer of the dependency. Another name for this approach is the interface segregation principle. In this case, we’ll create a network-adapter module that hides the real fetching functionality and has its own custom functions, as shown in figure 6.7.

06-07



图 6.7 包裹node-fetch与我们自己的模块network-adapter模块帮助我们仅公开应用程序所需的功能,并以最适合当前问题的语言表达。

Figure 6.7 Wrapping the node-fetch module with our own network-adapter module helps us expose only the functionality our application needs, expressed in the language most suitable for the problem at hand.

接口隔离原则

Interface segregation principle

术语“界面隔离原则”是由罗伯特·马丁创造的。想象一下数据库依赖项,其中隐藏在适配器后面的数十个函数,其接口可能只包含几个具有自定义名称和参数的函数。适配器用于隐藏复杂性并简化消费者的代码和模拟它的测试。有关接口隔离的更多信息,请参阅有关它的 Wikipedia 文章: https: //en.wikipedia.org/wiki/Interface_segregation_principle

The term interface segregation principle was coined by Robert Martin. Imagine a database dependency with dozens of functions hidden behind an adapter whose interface might only contain a couple of functions with custom names and parameters. The adapter serves to hide the complexity and simplify both the consumer’s code and the tests that simulate it. For more information on interface segregation, see the Wikipedia article about it: https://en.wikipedia.org/wiki/Interface_segregation_principle.

以下清单显示了该network-adapter模块的外观。

The following listing shows what the network-adapter module looks like.

清单 6.9network-adapter代码

Listing 6.9 The network-adapter code

const fetch = require("node-fetch");
 
const fetchUrlText = 异步(url)=> {
  const resp = 等待 fetch(url);
  if (resp.ok) {
    const text = wait resp.text();
    返回{确定:true,文本:文本};
  }
  return { ok: false, text: resp.statusText };
};   
const fetch = require("node-fetch");
 
const fetchUrlText = async (url) => {
  const resp = await fetch(url);
  if (resp.ok) {
    const text = await resp.text();
    return { ok: true, text: text };
  }
  return { ok: false, text: resp.statusText };
};   

请注意,该network-adapter模块是项目中唯一导入node-fetch. 如果该依赖关系在未来某个时刻发生变化,则这会增加仅当前文件需要更改的可能性。我们还通过名称和功能简化了该函数。我们隐藏了从 URL 获取状态和文本的需求,并将它们抽象为一个更易于使用的函数

Note that the network-adapter module is the only module in the project that imports node-fetch. If that dependency changes at some point in the future, this increases the chances that only the current file would need to change. We’ve also simplified the function both by name and by functionality. We’re hiding the need to fetch the status and the text from the URL, and we’re abstracting them both under a single easier-to-use function.

现在我们可以选择如何使用适配器。首先,我们可以以模块化的方式使用它。然后我们将使用函数式方法和具有强类型接口的面向对象方法。

Now we get to choose how to use the adapter. First, we can use it in the modular style. Then we’ll use a functional approach and an object-oriented one with a strongly typed interface.

模块化适配器

Modular adapter

network-adapter 下面的清单显示了我们初始函数的 模块化使用isWebsiteAlive()

The following listing shows a modular use of network-adapter by our initial isWebsiteAlive() function.

清单 6.10isWebsiteAlive()使用network-adapter模块

Listing 6.10 isWebsiteAlive() using the network-adapter module

const 网络 = require("./network-adapter");
 
const isWebsiteAlive = async () => {
  尝试 {
    const result =等待network.fetchUrlText(“http://example.com”) ;
    如果(!结果。确定){
      抛出结果.text;
    }
    常量文本=结果.文本;
    返回 processFetchSuccess(text);
  } 捕获(错误){
    抛出 processFetchFail(错误);
  }
};
const network = require("./network-adapter");
 
const isWebsiteAlive = async () => {
  try {
    const result = await network.fetchUrlText("http://example.com");
    if (!result.ok) {
      throw result.text;
    }
    const text = result.text;
    return processFetchSuccess(text);
  } catch (err) {
    throw processFetchFail(err);
  }
};

在此版本中,我们直接导入该network-adapter模块,稍后我们将在测试中伪造该模块。

In this version, we are directly importing the network-adapter module, which we’ll fake in our tests later on.

下面的列表显示了该模块的单元测试。jest.mock()因为我们使用模块化设计,所以我们可以伪造测试中使用的模块。我们还将在后面的示例中注入该模块,不用担心。

The unit tests for this module are shown in the following listing. Because we’re using a modular design, we can fake the module using jest.mock() in our tests. We’ll also inject the module in later examples, don’t worry.

清单 6.11network-adapter伪造jest.mock

Listing 6.11 Faking network-adapter with jest.mock

jest.mock("./网络适配器");                          
const StubSyncNetwork = require("./network-adapter");    
const webverifier = require("./website-verifier");
 
描述(“单元测试网站验证器”,()=> {
  beforeEach(jest.resetAllMocks);                        
 
  test("内容好,返回true", async () => {
    StubSyncNetwork.fetchUrlText.mockReturnValue({        
      好的:是的,
      文本:“说明性”,
    });
    const 结果 =等待webverifier.isWebsiteAlive();   
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
  });
 
  test("内容不良,返回 false", async () => {
    StubSyncNetwork.fetchUrlText.mockReturnValue({
      好的:是的,
      文本:“<span>你好世界</span>”,
    });
    const 结果 =等待webverifier.isWebsiteAlive();   
    期望(结果.成功).toBe(假);
    Expect(result.status).toBe("缺少文本");
  });
jest.mock("./network-adapter");                          
const stubSyncNetwork = require("./network-adapter");    
const webverifier = require("./website-verifier");
 
describe("unit test website verifier", () => {
  beforeEach(jest.resetAllMocks);                        
 
  test("with good content, returns true", async () => {
    stubSyncNetwork.fetchUrlText.mockReturnValue({       
      ok: true,
      text: "illustrative",
    });
    const result = await webverifier.isWebsiteAlive();   
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });
 
  test("with bad content, returns false", async () => {
    stubSyncNetwork.fetchUrlText.mockReturnValue({
      ok: true,
      text: "<span>hello world</span>",
    });
    const result = await webverifier.isWebsiteAlive();   
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });

伪造网络适配器模块

Faking the network-adapter module

导入假模块

Importing the fake module

重置所有桩以避免其他测试中出现任何潜在问题

Resetting all the stubs to avoid any potential issues in other tests

模拟桩模块的返回值

Simulating a return value from the stub module

在我们的测试中使用await

Using await in our tests

请注意,我们再次使用async/ await因为我们又回到了使用本章开头的原始入口点。但仅仅因为我们正在使用await并不意味着我们的测试是异步运行的。我们的测试代码及其调用的生产代码实际上是线性运行的,具有异步友好的签名。我们还需要将async/await用于功能和面向对象的设计,因为入口点需要它。

Notice that we are using async/await again, because we are back to using the original entry point we started with at the beginning of the chapter. But just because we’re using await doesn’t mean our tests are running asynchronously. Our test code, and the production code it invokes, actually runs linearly, with an async-friendly signature. We’ll need to use async/await for the functional and object-oriented designs as well, because the entry point requires it.

我命名了我们的假网络,stubSyncNetwork以使测试的同步性质更加清晰。否则,仅通过查看测试很难判断它调用的代码是线性运行还是异步运行。

I’ve named our fake network stubSyncNetwork to make the synchronous nature of the test clearer. Otherwise, it’s hard to tell just by looking at the test whether the code it invokes runs linearly or asynchronously.

功能适配器

Functional adapter

在功能设计模式中,模块的设计network-adapter保持不变,但我们website-verifier以不同的方式将其注入到我们的模块中。正如您在下一个清单中看到的,我们向入口点添加了一个新参数。

In the functional design pattern, the design of the network-adapter module stays the same, but we enable its injection into our website-verifier differently. As you can see in the next listing, we add a new parameter to our entry point.

清单 6.12 一个功能注入设计isWebsiteAlive()

Listing 6.12 A functional injection design for isWebsiteAlive()

const isWebsiteAlive = async (网络) => {
  const result =等待网络.fetchUrlText(“http://example.com”);
  如果(结果.ok){
    常量文本=结果.文本;
    返回onFetchSuccess(文本);
  }
  返回 onFetchError(result.text);
};
const isWebsiteAlive = async (network) => {
  const result = await network.fetchUrlText("http://example.com");
  if (result.ok) {
    const text = result.text;
    return onFetchSuccess(text);
  }
  return onFetchError(result.text);
};

在此版本中,我们期望network-adapter通过公共参数将模块注入到我们的函数中。在功能设计中,我们可以使用高阶函数和柯里化来配置具有我们自己的网络依赖项的预注入函数。在我们的测试中,我们可以简单地通过此参数发送一个假网络。就注入的设计而言,除了我们不再导入模块之外,与之前的示例相比几乎没有其他任何变化network-adapter。从长远来看,减少导入和需求量有助于可维护性。

In this version, we’re expecting the network-adapter module to be injected through a common parameter to our function. In a functional design, we can use higher-order functions and currying to configure a pre-injected function with our own network dependency. In our tests, we can simply send in a fake network via this parameter. As far as the design of the injection goes, almost nothing else has changed from previous samples, other than the fact that we don’t import the network-adapter module anymore. Reducing the amount of imports and requires can help maintainability in the long run.

下面的清单中我们的测试更简单,样板代码更少。

Our tests are simpler in the following listing, with less boilerplate code.

清单 6.13 带有功能注入的单元测试network-adapter

Listing 6.13 Unit test with functional injection of network-adapter

const webverifier = require("./website-verifier");
 
                  
  return { 
    fetchUrlText: () => { 
      return fakeResult; 
    }, 
  }; 
};
描述(“单元测试网站验证器”,()=> {
  test("内容好,返回true", async () => {
StubSyncNetwork = makeStubNetworkWithResult({
      好的:是的,
      文本:“说明性”,
    });
    const 结果 = 等待 webverifier.isWebsiteAlive( stubSyncNetwork );   
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
  });
 
  test("内容不良,返回 false", async () => {
    const StubSyncNetwork = makeStubNetworkWithResult({
      好的:是的,
      text: "意外内容",
    });
    const 结果 = 等待 webverifier.isWebsiteAlive( stubSyncNetwork );   
    期望(结果.成功).toBe(假);
    Expect(result.status).toBe("缺少文本");
  });
  ...   
const webverifier = require("./website-verifier");
 
                  
  return {
    fetchUrlText: () => {
      return fakeResult;
    },
  };
};
describe("unit test website verifier", () => {
  test("with good content, returns true", async () => {
stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "illustrative",
    });
    const result = await webverifier.isWebsiteAlive(stubSyncNetwork);   
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });
 
  test("with bad content, returns false", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "unexpected content",
    });
    const result = await webverifier.isWebsiteAlive(stubSyncNetwork);   
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });
  ...   

一个新的帮助函数,用于创建与网络适配器接口的重要部分相匹配的自定义对象

A new helper function to create a custom object that matches the important parts of the network-adapter’s interface

注入自定义对象

Injecting the custom object

请注意,我们不需要文件顶部的大量样板,就像我们在模块化设计中所做的那样。我们不需要间接伪造模块(通过jest.mock),我们不需要为我们的测试重新导入它(通过require),并且我们不需要使用 重置 Jest 的状态jest.resetAllMocks。我们需要做的就是makeStubNetworkWithResult从每个测试中调用新的辅助函数来生成一个新的假网络适配器,然后通过将其作为参数发送到我们的入口点来注入假网络。

Notice that we don’t need a lot of the boilerplate at the top of the file, as we did in the modular design. We don’t need to fake the module indirectly (via jest.mock), we don’t need to re-import it for our tests (via require), and we don’t need to reset Jest’s state using jest.resetAllMocks. All we need to do is call our new makeStubNetworkWithResult helper function from each test to generate a new fake network adapter, and then inject the fake network by sending it as a parameter to our entry point.

面向对象、基于接口的适配器

Object-oriented, interface-based adapter

我们研究了模块化和功能性设计。现在让我们将注意力转向等式的面向对象方面。在面向对象范式中,我们可以将之前完成的参数注入提升为构造函数注入模式。我们将从下面的清单中的网络适配器及其接口(公共 API 和结果签名)开始。

We’ve taken a look at the modular and functional designs. Let’s now turn our attention to the object-oriented side of the equation. In the object-oriented paradigm, we can take the parameter injection we’ve done before and promote it into a constructor injection pattern. We’ll start with the network adapter and its interfaces (public API and results signature) in the following listing.

清单 6.14NetworkAdapter及其接口

Listing 6.14 NetworkAdapter and its interfaces

导出接口INetworkAdapter {
  fetchUrlText(url: string): Promise<NetworkAdapterFetchResults>;
}
导出接口NetworkAdapterFetchResults {
  好的:布尔值;
  文本:字符串;
}
 
ch6-async/6-fetch-adapter-interface-oo/network-adapter.ts
    
导出类 NetworkAdapter实现 INetworkAdapter {
  异步 fetchUrlText(url: string) : 
        Promise<NetworkAdapterFetchResults> {
    const resp = 等待 fetch(url);
    if (resp.ok) {
      const text = wait resp.text();
      return Promise.resolve({ ok: true, text: text });
    }
    return Promise.reject({ ok: false, text: resp.statusText });
  }
}
export interface INetworkAdapter {
  fetchUrlText(url: string): Promise<NetworkAdapterFetchResults>;
}
export interface NetworkAdapterFetchResults {
  ok: boolean;
  text: string;
}
 
ch6-async/6-fetch-adapter-interface-oo/network-adapter.ts
    
export class NetworkAdapter implements INetworkAdapter {
  async fetchUrlText(url: string): 
        Promise<NetworkAdapterFetchResults> {
    const resp = await fetch(url);
    if (resp.ok) {
      const text = await resp.text();
      return Promise.resolve({ ok: true, text: text });
    }
    return Promise.reject({ ok: false, text: resp.statusText });
  }
}

在下一个清单中,我们创建一个WebsiteVerifier具有接收INetworkAdapter参数的构造函数的类。

In the next listing, we create a WebsiteVerifier class that has a constructor that receives an INetworkAdapter parameter.

清单 6.15WebsiteVerifier具有构造函数注入的类

Listing 6.15 WebsiteVerifier class with constructor injection

导出接口WebsiteAliveResult {
  成功:布尔值;
  状态:字符串;
}
 
导出类 WebsiteVerifier {
  构造函数(私有网络:INetworkAdapter) {}
 
  isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
    让 netResult: NetworkAdapterFetchResults ;
    尝试 {
    netResult =等待this.network.fetchUrlText (“http://example.com”);
      如果(!netResult.ok){
        抛出netResult.text;
      }
      const 文本 = netResult.text;
      返回 this.processNetSuccess(text);
    } 捕获(错误){
      抛出 this.processNetFail(err);
    }
  };
 
  processNetSuccess =(文本):WebsiteAliveResult => {
    const Include = text.includes("说明性");
    如果(包含){
      返回{成功:true,状态:“确定”};
    }
    return { success: false, status: "缺少文本" };
  };
 
  processNetFail = (err): WebsiteAliveResult => {
    返回 { 成功:错误,状态:错误 };
  };
}
export interface WebsiteAliveResult {
  success: boolean;
  status: string;
}
 
export class WebsiteVerifier {
  constructor(private network: INetworkAdapter) {}
 
  isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
    let netResult: NetworkAdapterFetchResults;
    try {
    netResult = await this.network.fetchUrlText("http://example.com");
      if (!netResult.ok) {
        throw netResult.text;
      }
      const text = netResult.text;
      return this.processNetSuccess(text);
    } catch (err) {
      throw this.processNetFail(err);
    }
  };
 
  processNetSuccess = (text): WebsiteAliveResult => {
    const included = text.includes("illustrative");
    if (included) {
      return { success: true, status: "ok" };
    }
    return { success: false, status: "missing text" };
  };
 
  processNetFail = (err): WebsiteAliveResult => {
    return { success: false, status: err };
  };
}

此类的单元测试可以实例化一个假网络适配器并通过构造函数注入它。在下面的清单中,我们将使用 Replace.js 创建一个适合新界面的假对象。

The unit tests for this class can instantiate a fake network adapter and inject it through a constructor. In the following listing, we’ll use substitute.js to create a fake object that fits the new interface.

清单 6.16 面向对象的单元测试WebsiteVerifier

Listing 6.16 Unit tests for the object-oriented WebsiteVerifier

const makeStubNetworkWithResult = (                         
  fakeResult: NetworkAdapterFetchResults 
): INetworkAdapter => { 
  const StubNetwork = Substitute.for<INetworkAdapter>();    
  StubNetwork.fetchUrlText(Arg.any())
    .returns(Promise.resolve( fakeResult ));                  
  返回桩网络;
} ;
 
描述(“单元测试网站验证器”,()=> {
  test("内容好,返回true", async () => {
    const StubSyncNetwork = makeStubNetworkWithResult({
      好的:是的,
      文本:“说明性”,
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork) ;
 
    const 结果 =等待 webVerifier.isWebsiteAlive();
    期望(结果.成功).toBe(true);
    期望(结果。状态)。toBe(“确定”);
  });
 
  test("内容不良,返回 false", async () => {
    const StubSyncNetwork = makeStubNetworkWithResult({
      好的:是的,
      text: "意外内容",
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork) ;
 
    const 结果 =等待 webVerifier.isWebsiteAlive();
    期望(结果.成功).toBe(假);
    Expect(result.status).toBe("缺少文本");
  });    
const makeStubNetworkWithResult = (                         
  fakeResult: NetworkAdapterFetchResults
): INetworkAdapter => {
  const stubNetwork = Substitute.for<INetworkAdapter>();    
  stubNetwork.fetchUrlText(Arg.any()) 
    .returns(Promise.resolve(fakeResult));                  
  return stubNetwork;
};
 
describe("unit test website verifier", () => {
  test("with good content, returns true", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "illustrative",
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork);
 
    const result = await webVerifier.isWebsiteAlive();
    expect(result.success).toBe(true);
    expect(result.status).toBe("ok");
  });
 
  test("with bad content, returns false", async () => {
    const stubSyncNetwork = makeStubNetworkWithResult({
      ok: true,
      text: "unexpected content",
    });
    const webVerifier = new WebsiteVerifier(stubSyncNetwork);
 
    const result = await webVerifier.isWebsiteAlive();
    expect(result.success).toBe(false);
    expect(result.status).toBe("missing text");
  });    

模拟网络适配器的辅助函数

Helper function to simulate the network adapter

生成假对象

Generating the fake object

使假对象返回测试所需的内容

Making the fake object return what the test requires

这种类型的控制反转(IOC)和依赖注入(DI) 效果很好。在面向对象的世界中,带有接口的构造函数注入非常常见,并且在许多情况下可以提供有效且可维护的解决方案,用于将依赖项与逻辑分离

This type of Inversion of Control (IOC) and Dependency Injection (DI) works well. In the object-oriented world, constructor injection with interfaces is very common and can, in many instances, provide a valid and maintainable solution for separating your dependencies from your logic.

6.3 处理定时器

6.3 Dealing with timers

计时器,例如setTimeout,代表了一个非常特定于 JavaScript 的问题。它们是域的一部分,并且无论好坏,都在许多代码片段中使用。有时,禁用这些功能并解决它们同样有用,而不是提取适配器和入口点。我们将研究两种绕过计时器的模式:

Timers, such as setTimeout, represent a very JavaScript-specific problem. They are part of the domain and are used, for better or worse, in many pieces of code. Instead of extracting adapters and entry points, sometimes it’s just as useful to disable these functions and work around them. We’ll look at two patterns for getting around timers:

  • 直接对函数进行猴子修补

  • Directly monkey-patching the function

  • 使用 Jest 和其他框架来禁用和控制它们

  • Using Jest and other frameworks to disable and control them

6.3.1 使用猴子补丁清除定时器

6.3.1 Stubbing timers out with monkey-patching

猴子补丁不在用于程序在本地扩展或修改支持系统软件(仅影响程序的运行实例)。JavaScript、Ruby 和 Python 等编程语言和运行时可以很容易地适应猴子补丁。对于 C# 和 Java 等强类型和编译时语言来说,做到这一点要困难得多。我在附录中更详细地讨论了猴子补丁

Monkey-patching is a way for a program to extend or modify supporting system software locally (affecting only the running instance of the program). Programming languages and runtimes such as JavaScript, Ruby, and Python can accommodate monkey-patching pretty easily. It’s much more difficult to do with more strongly typed and compile-time languages such as C# and Java. I discuss monkey-patching in more detail in the appendix.

这是在 JavaScript 中执行此操作的一种方法。我们将从使用该方法的以下代码开始setTimeout

Here’s one way to do it in JavaScript. We’ll start with the following piece of code that uses the setTimeout method.

清单 6.17setTimeout我们想要猴子修补的代码

Listing 6.17 Code with setTimeout we’d like to monkey-patch

constcalculate1 = (x, y, resultCallback) => {
   setTimeout( () => { resultCallback(x + y); },
    5000 ;
};
const calculate1 = (x, y, resultCallback) => {
  setTimeout(() => { resultCallback(x + y); },
    5000);
};

我们可以通过在内存中直接设置函数的原型来将函数猴子修补setTimeout为同步,如下所示。

We can monkey-patch the setTimeout function to be synchronous by literally setting that function’s prototype in memory, as follows.

清单 6.18 一个简单的猴子修补模式

Listing 6.18 A simple monkey-patching pattern

const Samples = require("./timing-samples");
 
描述(“猴子修补”,()=> {
 让原始超时;
 beforeEach(() => (originalTimeOut = setTimeout));    
 afterEach(() => (setTimeout = OriginalTimeOut));     
 
  测试(“计算1”,()=> {
    setTimeout = (回调, 毫秒) => 回调();         
    Samples.calculate1(1, 2, (结果) => {
        期望(结果).toBe(3);
    });
  });
});
const Samples = require("./timing-samples");
 
describe("monkey patching ", () => {
  let originalTimeOut;
  beforeEach(() => (originalTimeOut = setTimeout));    
  afterEach(() => (setTimeout = originalTimeOut));     
 
  test("calculate1", () => {
    setTimeout = (callback, ms) => callback();         
    Samples.calculate1(1, 2, (result) => {
        expect(result).toBe(3);
    });
  });
});

保存原来的setTimeout

Saving the original setTimeout

恢复原来的setTimeout

Restoring the original setTimeout

对 setTimeout 进行猴子修补

Monkey-patching the setTimeout

由于一切都是同步的,因此我们不需要done()等待回调调用。我们将替换setTimeout为立即调用接收到的回调的纯同步实现。

Since everything is synchronous, we don’t need to use done() to wait for a callback invocation. We are replacing setTimeout with a purely synchronous implementation that invokes the received callback immediately.

这种方法的唯一缺点是它需要一堆样板代码,并且通常更容易出错,因为我们需要记住正确清理。让我们看看像 Jest 这样的框架为我们提供了哪些框架来处理这些情况。

The only downside to this approach is that it requires a bunch of boilerplate code and is generally more error prone, since we need to remember to clean up correctly. Let’s look at what frameworks like Jest provide us with to handle these situations.

6.3.2 用 Jest 伪造 setTimeout

6.3.2 Faking setTimeout with Jest

Jest 为我们提供了三个主要函数来处理 JavaScript 中大多数类型的计时器:

Jest provides us with three major functions for handling most types of timers in JavaScript:

  • jest.useFakeTimers- 删除所有各种计时器功能,例如setTimetout

  • jest.useFakeTimers—Stubs out all the various timer functions, such as setTimetout

  • jest.resetAllTimers—将所有假计时器重置为真实计时器

  • jest.resetAllTimers—Resets all fake timers to the real ones

  • jest.advanceTimersToNextTimer— 触发任何假计时器,以便触发任何回调

  • jest.advanceTimersToNextTimer—Triggers any fake timer so that any callbacks are triggered

这些函数共同为我们处理了大部分样板代码。

Together, these functions take care of most of the boilerplate code for us.

这是我们刚刚在清单 6.18 中所做的相同测试,这次使用了 Jest 的辅助函数。

Here’s the same test we just did in listing 6.18, this time using Jest’s helper functions.

清单 6.19setTimeout开玩笑

Listing 6.19 Faking setTimeout with Jest

描述(“计算1 - 用笑话”,()=> {
  beforeEach(jest.clearAllTimers); 
 beforeEach(jest.useFakeTimers);
 
  test("带回调的假超时", () => {
    Samples.calculate1(1, 2, (结果) => {
      期望(结果).toBe(3);
    });
    jest.advanceTimersToNextTimer();
  });
});
describe("calculate1 - with jest", () => {
  beforeEach(jest.clearAllTimers);
  beforeEach(jest.useFakeTimers);
 
  test("fake timeout with callback", () => {
    Samples.calculate1(1, 2, (result) => {
      expect(result).toBe(3);
    });
    jest.advanceTimersToNextTimer();
  });
});

请注意,我们不需要调用done(),因为一切都是同步的。同时,我们必须使用advanceTimersToNextTimer,因为没有它,我们的假货setTimeout将永远被卡住。advanceTimersToNextTimer对于诸如正在测试的模块调度一个模块,而setTimeout该模块的回调又递归地调度另一个模块setTimeout(意味着调度永远不会停止)等场景也很有用。在这些场景中,能够及时、一步一步地向前运行是很有用的。

Notice that, once again, we don’t need to call done(), since everything is synchronous. At the same time, we have to use advanceTimersToNextTimer because, without it, our fake setTimeout would be stuck forever. advanceTimersToNextTimer is also useful for scenarios such as when the module being tested schedules a setTimeout whose callback schedules another setTimeout recursively (meaning the scheduling never stops). In these scenarios, it’s useful to be able to run forward in time, step by step.

使用advanceTimersToNextTimer,您可以将所有计时器提前指定的步骤数,以模拟将触发排队等待的下一个计时器回调的步骤的通过。

With advanceTimersToNextTimer, you could potentially advance all timers by a specified number of steps to simulate the passage of steps that will trigger the next timer callback waiting in line.

相同的模式也适用于setInterval,如下所示。

The same pattern also works well with setInterval, as shown next.

清单 6.20 使用的函数setInterval

Listing 6.20 A function that uses setInterval

constcalculate4 = (getInputsFn, resultFn) => {
   setInterval(() => {
    const { x, y } = getInputsFn();
    结果Fn(x + y);
  }, 1000);
};
const calculate4 = (getInputsFn, resultFn) => {
  setInterval(() => {
    const { x, y } = getInputsFn();
    resultFn(x + y);
  }, 1000);
};

在这种情况下,我们的函数接受两个回调作为参数:一个用于提供计算输入,另一个用于回调计算结果。它用于setInterval不断获取更多输入并计算其结果。

In this case, our function takes in two callbacks as parameters: one to provide the inputs to calculate, and the other to call back with the calculation result. It uses setInterval to continuously get more inputs and calculate their results.

下面的清单显示了一个测试,它将提前我们的计时器,触发间隔两次,并期望两次调用得到相同的结果。

The following listing shows a test that will advance our timer, trigger the interval twice, and expect the same result from both invocations.

清单 6.21 在单元测试中推进假定时器

Listing 6.21 Advancing fake timers in a unit test

描述(“按间隔计算”,()=> {
  beforeEach(jest.clearAllTimers);
  beforeEach(jest.useFakeTimers);
 
  test("计算,增加输入/输出,计算正确", () => {
    让x输入= 1;
    让 y 输入 = 2;
    const inputFn = () => ({ x: xInput++, y: yInput++ });      
    常量结果 = [];
    Samples.calculate4(inputFn, (结果) => results.push(结果));
 
    jest.advanceTimersToNextTimer();                           
    jest.advanceTimersToNextTimer();                           
 
    期望(结果[0]).toBe(3);
    期望(结果[1]).toBe(5);
  });
});
describe("calculate with intervals", () => {
  beforeEach(jest.clearAllTimers);
  beforeEach(jest.useFakeTimers);
 
  test("calculate, incr input/output, calculates correctly", () => {
    let xInput = 1;
    let yInput = 2;
    const inputFn = () => ({ x: xInput++, y: yInput++ });      
    const results = [];
    Samples.calculate4(inputFn, (result) => results.push(result));
 
    jest.advanceTimersToNextTimer();                           
    jest.advanceTimersToNextTimer();                           
 
    expect(results[0]).toBe(3);
    expect(results[1]).toBe(5);
  });
});

增加一个变量来验证回调的数量

Incrementing a variable to verify the number of callbacks

调用setInterval两次

Invoking setInterval twice

在此示例中,我们验证新值是否已正确计算和存储。请注意,我们可以仅使用一次调用和一次期望来编写相同的测试,并且我们将接近这个更复杂的测试提供的相同程度的置信度,但我喜欢在需要更多时进行额外的验证信心。

In this example, we verify that the new values are being calculated and stored correctly. Notice that we could have written the same test with only a single invocation and a single expect, and we would have gotten close to the same amount of confidence that this more elaborate test provides, but I like to put in additional validation when I need more confidence.

6.4 处理常见事件

6.4 Dealing with common events

我不能谈论异步单元测试而不讨论基本事件流。希望异步单元测试的主题现在看起来相对简单,但我想明确地回顾一下事件部分。

I can’t talk about async unit testing and not discuss the basic events flow. Hopefully the topic of async unit testing now seems relatively straightforward, but I want to go over the events part explicitly.

6.4.1 处理事件发射器

6.4.1 Dealing with event emitters

为了确保我们都在同一页面上,以下是 DigitalOcean 的“在 Node.js 中使用事件发射器”教程 ( http://mng.bz/844z ) 中对事件发射器的清晰简洁的定义:

To make sure we’re all on the same page, here’s a clear and concise definition of event emitters from DigitalOcean’s “Using Event Emitters in Node.js” tutorial (http://mng.bz/844z):

事件发射器是 Node.js 中的对象,它们通过发送消息来指示操作已完成来触发事件。JavaScript 开发人员可以编写代码来侦听来自事件发射器的事件,从而允许他们在每次触发这些事件时执行函数。在此上下文中,事件由标识字符串和需要传递给侦听器的任何数据组成。

Event emitters are objects in Node.js that trigger an event by sending a message to signal that an action was completed. JavaScript developers can write code that listens to events from an event emitter, allowing them to execute functions every time those events are triggered. In this context, events are composed of an identifying string and any data that needs to be passed to the listeners.

考虑Adder下面清单中的类,它每次添加内容时都会发出一个事件。

Consider the Adder class in the following listing, which emits an event every time it adds something.

清单 6.22 一个简单的基于事件发射器的Adder

Listing 6.22 A simple event-emitter-based Adder

const EventEmitter = require("事件");
 
类 Adder扩展了 EventEmitter {
  构造函数(){
    极好的();
  }
 
  添加(x,y){
    常量结果 = x + y;
    this.emit("添加", 结果);
    返回结果;
  }
}
const EventEmitter = require("events");
 
class Adder extends EventEmitter {
  constructor() {
    super();
  }
 
  add(x, y) {
    const result = x + y;
    this.emit("added", result);
    return result;
  }
}

编写验证事件是否已发出的单元测试的最简单方法是在我们的测试中订阅该事件,并验证它在我们调用该add函数时是否触发。

The simplest way to write a unit test that verifies that the event is emitted is to literally subscribe to the event in our test and verify that it triggers when we call the add function.

清单 6.23 通过订阅来测试事件发射器

Listing 6.23 Testing an event emitter by subscribing to it

描述(“基于事件的模块”,()=> {
  描述(“添加”,()=> {
    it("调用时生成加法事件", ( done ) => {
      常量加法器=新加法器();
     adder.on("已添加", (结果) => {
        期望(结果).toBe(3);
        完成(); 
     }); 
     加法器.add(1, 2);
    });
  });
});
describe("events based module", () => {
  describe("add", () => {
    it("generates addition event when called", (done) => {
      const adder = new Adder();
      adder.on("added", (result) => {
        expect(result).toBe(3);
        done();
      });
      adder.add(1, 2);
    });
  });
});

通过使用done(),我们正在验证事件是否确实已发出。如果我们不使用done(),并且事件没有发出,我们的测试就会通过,因为订阅的代码从未执行。通过添加expect(x).toBe(y),我们还验证事件参数中发送的值,并隐式测试事件是否被触发。

By using done(), we are verifying that the event actually was emitted. If we didn’t use done(), and the event wasn’t emitted, our test would pass because the subscribed code never executed. By adding expect(x).toBe(y), we are also verifying the values sent in the event parameters, as well as implicitly testing that the event was triggered.

6.4.2 处理点击事件

6.4.2 Dealing with click events

那些烦人的 UI 事件(例如 )怎么办click?我们如何通过脚本测试是否正确绑定了它们?考虑清单 6.24 和 6.25 中的简单网页和相关逻辑。

What about those pesky UI events, such as click? How can we test that we have bound them correctly via our scripts? Consider the simple web page and associated logic in listings 6.24 and 6.25.

click清单 6.24 一个带有 JavaScript功能的简单网页

Listing 6.24 A simple web page with JavaScript click functionality

<!DOCTYPE html>
<html lang="en">
<头>
    <元字符集=“UTF-8”>
    <title>待测试文件</title>
    <script src="index-helper.js"></script>
</头>
<正文>
    <div>
        <div>一个简单的按钮</div>
        <Button data-testid="myButton" id="myButton">点击我</Button>
        <div data-testid="myResult" id="myResult">等待...</div>
    </div>
</正文>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>File to Be Tested</title>
    <script src="index-helper.js"></script>
</head>
<body>
    <div>
        <div>A simple button</div>
        <Button data-testid="myButton" id="myButton">Click Me</Button>
        <div data-testid="myResult" id="myResult">Waiting...</div>
    </div>
</body>
</html> 

清单 6.25 JavaScript 中的网页逻辑

Listing 6.25 The logic for the web page in JavaScript

window.addEventListener("加载", () => {
  文档
    .getElementById("myButton")
    .addEventListener("点击", onMyButtonClick);
 
  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "文档已加载";
});
 
函数 onMyButtonClick() {
  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "点击了!";
}
window.addEventListener("load", () => {
  document
    .getElementById("myButton")
    .addEventListener("click", onMyButtonClick);
 
  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "Document Loaded";
});
 
function onMyButtonClick() {
  const resultDiv = document.getElementById("myResult");
  resultDiv.innerText = "Clicked!";
}

我们有一个非常简单的逻辑,可以确保我们的按钮在单击时设置特殊消息。我们如何测试这个?

We have a very simple piece of logic that makes sure our button sets a special message when clicked. How can we test this?

这是一个反模式:我们可以在测试中订阅点击事件并确保它被触发,但这对我们没有任何价值。我们关心的是,除了触发之外,点击实际上做了一些有用的事情。

Here’s an antipattern: we could subscribe to the click event in our tests and make sure it is triggered, but this would provide no value to us. What we care about is that the click has actually done something useful, other than triggering.

这是一个更好的方法:我们可以触发点击事件并确保它已更改页面内的正确值 - 这将提供真正的价值。图 6.8 显示了这一点。

Here’s a better way: we can trigger the click event and make sure it has changed the correct value inside the page—this will provide real value. Figure 6.8 shows this.

06-08



图6.8 Click作为入口点,element作为出口点

Figure 6.8 Click as an entry point, and element as an exit point

下面的清单显示了我们的测试可能是什么样子。

The following listing shows what our test might look like.

清单 6.26 触发点击事件并测试元素的文本

Listing 6.26 Triggering a click event, and testing an element’s text

/**
 * @jest-environment jsdom                                  
 */
//(以上是窗口事件所需要的)
const fs = require("fs");
const 路径 = require("路径");
需要(“./index-helper.js”);
const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.xhtml");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
};
 
const loadHtmlAndGetUIElements = () => {
  loadHtml("index.xhtml");
  const 按钮 = document.getElementById("myButton");
  const resultDiv = document.getElementById("myResult");
  返回 { 窗口,按钮,resultDiv };
};
 
描述(“索引助手”,()=> {
  test("普通按钮点击触发结果 div 中的更改", () => {
    const { 窗口、按钮、resultDiv } = loadHtmlAndGetUIElements();
    window.dispatchEvent(new Event("load"));               
 
    按钮.单击();                                        
 
    Expect(resultDiv.innerText).toBe(“点击了!”);          
  });
});   
/**
 * @jest-environment jsdom                                 
 */
//(the above is required for window events)
const fs = require("fs");
const path = require("path");
require("./index-helper.js");
const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.xhtml");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
};
 
const loadHtmlAndGetUIElements = () => {
  loadHtml("index.xhtml");
  const button = document.getElementById("myButton");
  const resultDiv = document.getElementById("myResult");
  return { window, button, resultDiv };
};
 
describe("index helper", () => {
  test("vanilla button click triggers change in result div", () => {
    const { window, button, resultDiv } = loadHtmlAndGetUIElements();
    window.dispatchEvent(new Event("load"));               
 
    button.click();                                        
 
    expect(resultDiv.innerText).toBe("Clicked!");          
  });
});   

针对该文件应用浏览器模拟jsdom环境

Applying the browser-simulating jsdom environment just for this file

模拟document.load事件

Simulating the document.load event

触发点击

Triggering the click

验证文档中的元素是否确实发生了更改

Verifying that an element in our document has actually changed

在此示例中,我提取了两个实用程序方法loadHtmlloadHtmlAndGetUIElements,以便我可以编写更清晰、更易读的测试,并且如果将来 UI 项位置或 ID 发生更改,更改测试时会遇到更少的问题。

In this example, I’ve extracted two utility methods, loadHtml and loadHtmlAndGetUIElements, so that I can write cleaner, more readable tests, and so I’ll have fewer issues changing my tests if UI item locations or IDs change in the future.

在测试本身中,我们模拟document.load事件,以便我们的测试下的自定义脚本可以开始运行,然后触发click,就像用户单击了按钮一样。最后,测试验证文档中的元素实际上已更改,这意味着我们的代码成功订阅了事件并完成了工作。

In the test itself, we’re simulating the document.load event, so that our custom script under test can start running and then triggering the click, as if the user had clicked the button. Finally, the test verifies that an element in our document has actually changed, which means our code successfully subscribed to the event and did its work.

请注意,我们实际上并不关心索引帮助程序文件内的底层逻辑。我们只依赖于 UI 中观察到的状态变化,它作为我们的最终退出点。这允许我们的测试中更少的耦合,因此,如果我们的测试代码发生变化,我们不太可能需要更改测试,除非可观察的(公开可见的)功能确实发生了变化。

Notice that we don’t actually care about the underlying logic inside the index helper file. We just rely on observed state changes in the UI, which acts as our final exit point. This allows less coupling in our tests, so that if our code under test changes, we are less likely to need to change the test, unless the observable (publicly noticeable) functionality has truly changed.

6.5 引入DOM测试库

6.5 Bringing in the DOM testing library

我们的测试有很多样板代码,主要用于查找元素并验证其内容。我建议查看 Kent C. Dodds 编写的开源 DOM 测试库 ( https://github.com/kentcdodds/dom-testing-library-with-anything )。该库具有适用于当今大多数前端 JavaScript 框架的变体,例如 React、Angular 和 Vue.js。我们将使用它的普通版本,名为 DOM 测试库。

Our test has a lot of boilerplate code, mostly for finding elements and verifying their contents. I recommend looking into the open source DOM Testing Library written by Kent C. Dodds (https://github.com/kentcdodds/dom-testing-library-with-anything). This library has variants applicable to most frontend JavaScript frameworks today, such as React, Angular, and Vue.js. We’ll be using the vanilla version of it named DOM Testing Library.

我喜欢这个库的原因是它的目的是让我们能够更接近与网页交互的用户的角度来编写测试。我们不使用元素 ID,而是通过元素文本进行查询;触发事件更加干净一些;查询和等待元素出现或消失更干净,并且隐藏在语法糖下。一旦你在多次测试中使用它,它就非常有用。

What I like about this library is that it aims to allow us to write tests closer to the point of view of the user interacting with our web page. Instead of using IDs for elements, we query by element text; firing events is a bit cleaner; and querying and waiting for elements to appear or disappear is cleaner and hidden under syntactic sugar. It’s quite useful once you use it in multiple tests.

这是我们对该库的测试结果。

Here’s what our test looks like with this library.

清单 6.27 在简单测试中使用 DOM 测试库

Listing 6.27 Using the DOM Testing Library in a simple test

const { fireEvent, findByText, getByText }                                  
    = require("@testing-library/dom");                                     
 
const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.xhtml");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
  返回文档.documentElement;                                         
};
 
const loadHtmlAndGetUIElements = () => {
  const docElem = loadHtml("index.xhtml");
  const 按钮 = getByText(docElem, "点击我", { 精确: false }); 
  返回 { 窗口, docElem , 按钮 };
};
 
描述(“索引助手”,()=> {
  test("dom test lib按钮点击触发页面变化", () => {
    const { 窗口,docElem,按钮 } = loadHtmlAndGetUIElements();
   fireEvent.load(窗口);                                                
 
   fireEvent.click(按钮);                                               
 
    //等待 true 或 1 秒内超时
    Expect(findByText(docElem,"clicked", { excact: false })).toBeTruthy();  
  });
});
const { fireEvent, findByText, getByText }                                 
    = require("@testing-library/dom");                                     
 
const loadHtml = (fileRelativePath) => {
  const filePath = path.join(__dirname, "index.xhtml");
  const innerHTML = fs.readFileSync(filePath);
  document.documentElement.innerHTML = innerHTML;
  return document.documentElement;                                         
};
 
const loadHtmlAndGetUIElements = () => {
  const docElem = loadHtml("index.xhtml");
  const button = getByText(docElem, "click me", { exact: false });
  return { window, docElem, button };
};
 
describe("index helper", () => {
  test("dom test lib button click triggers change in page", () => {
    const { window, docElem, button } = loadHtmlAndGetUIElements();
    fireEvent.load(window);                                                
 
    fireEvent.click(button);                                               
 
    //wait until true or timeout in 1 sec
    expect(findByText(docElem,"clicked", { exact: false })).toBeTruthy();  
  });
});

导入一些要用到的库API

Importing some of the library APIs to be used

库 API 需要文档元素作为大部分工作的基础。

Library APIs require the document element as the basis for most of the work.

使用库的 fireEvent API 来简化事件调度

Using the library’s fireEvent API to simplify event dispatching

该查询将等待直到找到该项目,否则将在 1 秒内超时。

This query will wait until the item is found or will timeout within 1 second.

请注意该库如何允许我们使用页面项目的常规文本来获取项目,而不是它们的 ID 或测试 ID。这是图书馆推动我们工作的方式的一部分,因此从用户的角度来看,事情感觉更自然。为了使测试随着时间的推移更具可持续性,我们使用了该exact: false标志,这样我们就不必担心大写问题或字符串开头或结尾丢失字母。这消除了对不太重要的小文本更改更改测试的需要

Notice how the library allows us to use the regular text of the page items to get the items, instead of their IDs or test IDs. This is part of the way the library pushes us to work so things feel more natural and from the user’s point of view. To make the test more sustainable over time, we’re using the exact: false flag so that we don’t have to worry about uppercasing issues or missing letters at the start or end of strings. This removes the need to change the test for small text changes that are less important.

概括

Summary

  • 测试异步代码会直接导致不稳定的测试,需要很长时间才能执行。要解决这些问题,您可以采取两种方法:提取入口点或提取适配器。

  • Testing asynchronous code directly results in flaky tests that take a long time to execute. To fix these issues, you can take two approaches: extract an entry point or extract an adapter.

  • 提取入口点是将纯逻辑提取到单独的函数中并将这些函数视为测试的入口点。提取的入口点可以接受回调作为参数,也可以返回一个值。为了简单起见,更喜欢返回值而不是回调。

  • Extracting an entry point is when you extract the pure logic into separate functions and treat those functions as entry points for your tests. The extracted entry point can either accept a callback as an argument or return a value. Prefer return values over callbacks for simplicity.

  • 提取适配器涉及提取本质上异步的依赖项并将其抽象出来,以便您可以用同步的东西替换它。适配器可能有不同类型:

    • 模块化——当您桩整个模块(文件)并替换其中的特定函数时。

    • 功能性——当您将函数或值注入到被测系统中时。您可以在测试中用桩替换注入的值。

    • 面向对象——当您在生产代码中使用接口并创建在测试代码中实现该接口的桩时。

  • Extracting an adapter involves extracting a dependency that is inherently asynchronous and abstracting it away so that you can replace it with something that is synchronous. The adapter may be of different types:

    • Modular—When you stub the whole module (file) and replace specific functions in it.

    • Functional—When you inject a function or value into the system under test. You can replace the injected value with a stub in tests.

    • Object-oriented—When you use an interface in the production code and create a stub that implements that interface in the test code.

  • 计时器(例如setTimeoutsetInterval)可以直接用猴子补丁替换,也可以使用 Jest 或其他框架来禁用和控制它们。

  • Timers (such as setTimeout and setInterval) can be replaced either directly with monkey-patching or by using Jest or another framework to disable and control them.

  • 最好通过验证事件产生的最终结果(用户可以看到的 HTML 文档中的更改)来测试事件。您可以直接执行此操作,也可以使用 DOM 测试库等库来执行此操作

  • Events are best tested by verifying the end result they produce—changes in the HTML document the user can see. You can do this either directly or by using libraries such as the DOM Testing Library.

第三部分 测试代码

Part 3 The test code

本部分涵盖了管理和组织单元测试以及确保实际项目中单元测试高质量的技术。

This part covers techniques for managing and organizing unit tests and for ensuring that the quality of unit tests in real-world projects is high.

第 7 章介绍了测试可信度。它解释了如何编写能够可靠地报告错误存在或不存在的测试。我们还将研究真实测试失败和错误测试失败之间的差异。

Chapter 7 covers test trustworthiness. It explains how to write tests that will reliably report the presence or absence of bugs. We’ll also look at the differences between true and false test failures.

在第 8 章中,我们将探讨良好单元测试的主要支柱——可维护性——并将探索支持它的技术。为了使测试长期有用,它们不应该需要太多的精力来维护;否则,他们将不可避免地被抛弃。

In chapter 8, we’ll look at the main pillar of good unit tests—maintainability—and we’ll explore techniques to support it. For tests to be useful in the long run, they shouldn’t require much effort to maintain; otherwise, they will inevitably become abandoned.

7 项值得信赖的测试

7 Trustworthy tests

本章涵盖

This chapter covers

  • 如何知道您信任测试
  • How to know you trust a test
  • 检测不可信的失败测试
  • Detecting untrustworthy failing tests
  • 检测不可信的通过测试
  • Detecting untrustworthy passing tests
  • 处理不稳定的测试
  • Dealing with flaky tests

无论您如何组织测试,或者拥有多少测试,如果您不能信任它们、维护它们或阅读它们,它们就毫无价值。您编写的测试应该具有三个共同使它们变得良好的属性:

No matter how you organize your tests, or how many you have, they’re worth very little if you can’t trust them, maintain them, or read them. The tests that you write should have three properties that together make them good:

  • 可信度——开发人员希望运行可信的测试,并且他们会充满信心地接受测试结果。值得信赖的测试没有错误,它们测试的是正确的东西。

  • Trustworthiness—Developers will want to run trustworthy tests, and they’ll accept the test results with confidence. Trustworthy tests don’t have bugs, and they test the right things.

  • 可维护性——不可维护的测试是噩梦,因为它们可能会破坏项目进度,或者当项目的进度安排更加紧迫时,它们可能会被搁置。开发人员将停止维护和修复那些需要很长时间才能更改或需要经常更改的非常小的生产代码更改的测试。

  • Maintainability—Unmaintainable tests are nightmares because they can ruin project schedules, or they may be sidelined when the project is put on a more aggressive schedule. Developers will simply stop maintaining and fixing tests that take too long to change or that need to change often on very minor production code changes.

  • 可读性——这不仅指能够阅读测试,还指在测试似乎错误时找出问题。如果没有可读性,其他两个支柱很快就会倒塌。维护测试变得更加困难,并且您不能再信任它们,因为您不理解它们。

  • Readability—This refers not only to being able to read a test but also figuring out the problem if the test seems to be wrong. Without readability, the other two pillars fall pretty quickly. Maintaining tests becomes harder, and you can’t trust them anymore because you don’t understand them.

本章和接下来的两章介绍了与每个支柱相关的一系列实践,您可以在进行测试评审时使用它们。三大支柱共同确保您的时间得到充分利用。丢掉其中一个,你就有可能浪费每个人的时间。

This chapter and the next two present a series of practices related to each of these pillars that you can use when doing test reviews. Together, the three pillars ensure your time is well used. Drop one of them, and you run the risk of wasting everyone’s time.

信任是我喜欢评估良好单元测试的三个支柱中的第一个,因此我们从它开始是合适的。如果我们不信任这些测试,那么运行它们还有什么意义呢?如果它们失败了,修复它们或修复代码有什么意义呢?维护它们有什么意义?

Trust is the first of the three pillars that I like to evaluate good unit tests on, so it’s fitting that we start with it. If we don’t trust the tests, what’s the point in running them? What’s the point in fixing them or fixing the code if they fail? What’s the point of maintaining them?

7.1 如何知道您信任某个测试

7.1 How to know you trust a test

在测试环境中,“信任”对于软件开发人员意味着什么?也许根据测试失败或通过时我们做什么或不做什么来解释更容易。

What does “trust” mean for a software developer in the context of a test? Perhaps it’s easier to explain based on what we do or don’t do when a test fails or passes.

如果出现以下情况,您可能不信任测试

You might not trust a test if

  • 它失败了,但您并不担心(您认为这是误报)。

  • It fails and you’re not worried (you believe it’s a false positive).

  • 您觉得忽略此测试的结果没什么问题,要么是因为它偶尔会通过,要么是因为您觉得它不相关或有问题。

  • You feel like it’s fine to ignore the results of this test, either because it passes every once in a while or because you feel it’s not relevant or buggy.

  • 它过去了,你很担心(你认为这是假阴性)。

  • It passes and you are worried (you believe it’s a false negative).

  • 您仍然觉得需要手动调试或测试软件“以防万一”。

  • You still feel the need to manually debug or test the software “just in case.”

如果出现以下情况,您可能会相信该测试

You might trust the test if

  • 测试失败,您真的担心有什么东西坏了。你不会继续前进,假设测试是错误的。

  • The test fails and you’re genuinely worried that something broke. You don’t move on, assuming the test is wrong.

  • 测试通过,您会感到轻松,不需要手动测试或调试。

  • The test passes and you feel relaxed, not feeling the need to test or debug manually.

在接下来的几节中,我们将把测试失败作为识别不可信测试的一种方法,我们将研究通过测试的代码并了解如何检测不可信的测试代码。最后,我们将介绍一些可以增强测试可信度的通用实践。

In the next few sections, we’ll look at test failures as a way to identify untrustworthy tests, and we’ll look at passing tests’ code and see how to detect untrustworthy test code. Finally, we’ll cover a few generic practices that can enhance trustworthiness in tests.

7.2 为什么测试失败

7.2 Why tests fail

理想情况下,您的测试(任何测试,而不仅仅是单元测试)应该只因有充分的理由而失败。当然,这个充分的理由是在底层生产代码中发现了一个真正的错误。

Ideally, your tests (any tests, not just unit tests) should only be failing for a good reason. That good reason is, of course, that a real bug was uncovered in the underlying production code.

不幸的是,测试可能会因多种原因而失败。我们可以假设测试因除一个充分理由之外的任何原因而失败应该触发“不可信”警告,但并非所有测试都以相同的方式失败,并且认识到测试可能失败的原因可以帮助我们为我们的目标制定路线图我想在每种情况下做。

Unfortunately, tests can fail for a multitude of reasons. We can assume that a test failing for any reason other than that one good reason should trigger an “untrustworthy” warning, but not all tests fail the same way, and recognizing the reasons tests may fail can help us build a roadmap for what we’d like to do in each case.

以下是测试失败的一些原因:

Here are some reasons that tests fail:

  • 生产代码中发现了一个真正的错误

  • A real bug has been uncovered in the production code

  • 有缺陷的测试给出错误的失败

  • A buggy test gives a false failure

  • 由于功能更改,该测试已过时

  • The test is out of date due to a change in functionality

  • 该测试与另一个测试冲突

  • The test conflicts with another test

  • 测试是片状的

  • The test is flaky

除了这里的第一点之外,所有这些原因都是测试告诉您它不应该以当前的形式被信任。让我们来看看它们。

Except for the first point here, all these reasons are the test telling you it should not be trusted in its current form. Let’s go through them.

7.2.1 在生产代码中发现了一个真正的错误

7.2.1 A real bug has been uncovered in the production code

测试失败的第一个原因是生产代码中存在错误。那挺好的!这就是我们进行测试的原因。让我们继续讨论测试失败的其他原因。

The first reason a test will fail is when there is a bug in the production code. That’s good! That’s why we have tests. Let’s move on to the other reasons tests fail.

7.2.2 有缺陷的测试给出错误的失败

7.2.2 A buggy test gives a false failure

如果测试有错误,测试就会失败。生产代码可能是正确的,但如果测试本身存在导致测试失败的错误,那也没关系。可能是您断言退出点的预期结果错误,或者您错误地使用了被测系统。可能是您错误地设置了测试上下文,或者您误解了应该测试的内容。

A test will fail if the test is buggy. The production code might be correct, but that doesn’t matter if the test itself has a bug that causes the test to fail. It could be that you’re asserting on the wrong expected result of an exit point, or that you’re using the system under test incorrectly. It could be that you’re setting up the context for the test wrong or that you misunderstand what you were supposed to test.

无论哪种方式,有错误的测试都可能非常危险,因为测试中的错误也可能导致测试通过并使您不知道到底发生了什么。我们将在本章后面详细讨论不会失败但应该失败的测试。

Either way, a buggy test can be quite dangerous, because a bug in a test can also cause it to pass and leave you unsuspecting of what’s really going on. We’ll talk more about tests that don’t fail but should later in the chapter.

如何识别有缺陷的测试

How to recognize a buggy test

您的测试失败,但您可能已经调试了生产代码并且找不到任何错误。这时您应该开始怀疑失败的测试。没有办法解决这个问题。您将不得不慢慢调试测试代码。

You have a failing test, but you might have already debugged the production code and couldn’t find any bug there. This is when you should start suspecting the failing test. There’s no way around it. You’re going to have to slowly debug the test code.

以下是错误故障的一些潜在原因:

Here are some potential causes of false failures:

  • 断言错误的事情或错误的退出点

  • Asserting on the wrong thing or on the wrong exit point

  • 向入口点注入错误的值

  • Injecting a wrong value into the entry point

  • 错误地调用入口点

  • Invoking the entry point incorrectly

这也可能是您在凌晨 2 点编写代码时发生的其他一些小错误(顺便说一句,这不是一个可持续的编码策略。停止这样做。)

It could also be some other small mistake that happens when you write code at 2 A.M. (That’s not a sustainable coding strategy, by the way. Stop doing that.)

一旦发现有问题的测试,你会做什么?

What do you do once you’ve found a buggy test?

当您发现有错误的测试时,不要惊慌。这可能是您发现的第一百万次,因此您可能会感到恐慌并认为“我们的测试很糟糕”。你可能也是对的。但这并不意味着您应该惊慌。修复错误,然后运行测试以查看现在是否通过。

When you find a buggy test, don’t panic. This might be the millionth time you’ve found one, so you might be panicking and thinking “our tests suck.” You might also be right about that. But that doesn’t mean you should panic. Fix the bug, and run the test to see if it now passes.

如果测试通过了,别高兴得太早!转到生产代码并放置一个应该由新修复的测试捕获的明显错误。例如,将布尔值更改为始终为true。或者false。然后再次运行测试,并确保它失败。如果没有,您的测试中可能仍然存在错误。修复测试,直到找到生产错误并且您可以看到它失败。

If the test passes, don’t be happy too soon! Go to the production code and place an obvious bug that should be caught by the newly fixed test. For example, change a Boolean to always be true. Or false. Then run the test again, and make sure it fails. If it doesn’t, you might still have a bug in your test. Fix the test until it can find the production bug and you can see it fail.

一旦您确定测试因明显的生产代码问题而失败,请修复您刚刚提出的生产代码问题并再次运行测试。它应该过去。如果现在测试通过了,那么你就完成了。您现在已经看到测试在应该通过的时候通过,在应该失败的时候失败。提交代码并继续。

Once you are sure the test is failing for an obvious production code issue, fix the production code issue you just made and run the test again. It should pass. If the test is now passing, you’re done. You’ve now seen the test passing when it should and failing when it should. Commit the code and move on.

如果测试仍然失败,则可能存在另一个错误。再次重复整个过程,直到验证测试失败并在应该通过时通过。如果测试仍然失败,您可能在生产代码中遇到了真正的错误。在这种情况下,对你有好处!

If the test is still failing, it might have another bug. Repeat the whole process again until you verify that the test fails and passes when it should. If the test is still failing, you might have come across a real bug in production code. In which case, good for you!

如何避免将来的测试出现错误

How to avoid buggy tests in the future

据我所知,检测和防止错误测试的最佳方法之一是以测试驱动的方式编写代码。我在本书的第一章中对此技术进行了一些解释。我也在现实生活中练习这种技术。

One of the best ways I know to detect and prevent buggy tests is to write your code in a test-driven manner. I explained a bit about this technique in chapter 1 of this book. I also practice this technique in real life.

测试驱动开发 (TDD) 允许我们看到测试的两种状态:当它应该失败时(这是我们开始的初始状态)以及当它应该通过时(当被测试的生产代码写入时)使测试通过)。如果测试仍然失败,我们就在生产代码中发现了错误。如果测试开始通过,则测试中存在错误。

Test-driven development (TDD) allows us to see both states of a test: both that it fails when it should (that’s the initial state we start in) and that it passes when it should (when the production code under test is written to make the test pass). If the test continues to fail, we’ve found a bug in the production code. If the test starts out passing, we have a bug in the test.

减少测试中出现错误的可能性的另一个好方法是删除其中的逻辑。更多内容请参见第 7.3 节。

Another great way to reduce the likelihood of bugs in tests is to remove logic from them. More on this in section 7.3.

7.2.3 由于功能更改,测试已过时

7.2.3 The test is out of date due to a change in functionality

一个测试可以失败如果它不再与当前正在测试的功能兼容。假设您有登录功能,并且在早期版本中,您需要提供用户名和密码才能登录。在新版本中,双因素身份验证方案取代了旧的登录方式。现有测试将开始失败,因为它没有为登录功能提供正确的参数

A test can fail if it’s no longer compatible with the current feature that’s being tested. Say you have a login feature, and in an earlier version, you needed to provide a username and a password to log in. In the new version, a two-factor authentication scheme replaced the old login. The existing test will start failing because it’s not providing the right parameters to the login functions.

你现在可以做什么?

What can you do now?

您现在有两个选择:

You now have two options:

  • 使测试适应新功能。

  • Adapt the test to the new functionality.

  • 为新功能编写新测试,并删除旧测试,因为它现在已变得无关紧要。

  • Write a new test for the new functionality, and remove the old test because it has now become irrelevant.

避免或防止将来发生这种情况

Avoiding or preventing this in the future

事情会改变的。我认为在某个时间点不可能没有过时的测试。我们将在下一章中处理变更,涉及测试的可维护性以及测试如何处理应用程序中的变更。

Things change. I don’t think it’s possible to not have out-of-date tests at some point in time. We’ll deal with change in the next chapter, relating to the maintainability of tests and how well tests can handle changes in the application.

7.2.4 测试与另一个测试冲突

7.2.4 The test conflicts with another test

让我们假设您有两项测试:其中一项失败,一项通过。我们还假设他们不能一起通过。您通常只会看到失败的测试,因为通过的测试已经通过了。

Let’s say you have two tests: one of them is failing and one is passing. Let’s also say they cannot pass together. You’ll usually only see the failing test, because the passing one is, well, passing.

例如,测试可能会失败,因为它突然与新行为发生冲突。另一方面,冲突的测试可能期望新的行为,但没有找到它。最简单的示例是,第一个测试验证调用具有两个参数的函数会生成“3”,而第二个测试则期望相同的函数生成“4”。

For instance, a test may fail because it suddenly conflicts with a new behavior. On the other hand, a conflicting test may expect a new behavior but doesn’t find it. The simplest example is when the first test verifies that calling a function with two parameters produces “3,” whereas the second test expects the same function to produce “4.”

你现在可以做什么?

What can you do now?

根本原因是其中一项测试变得无关紧要,这意味着需要将其删除。应该删除哪一个?这是我们需要问产品所有者的问题,因为答案与应用程序的正确行为和预期行为有关。

The root cause is that one of the tests has become irrelevant, which means it needs to be removed. Which one should be removed? That’s a question we’d need to ask a product owner, because the answer is related to which behavior is correct and expected from the application.

将来避免这种情况

Avoiding this in the future

我觉得这是一种健康的动态,我可以不回避它。

I feel this is a healthy dynamic, and I’m fine with not avoiding it.

7.2.5 测试不稳定

7.2.5 The test is flaky

测试可能会不一致地失败。即使被测试的生产代码没有更改,测试也可能会在没有任何明显原因的情况下突然失败,然后再次通过,然后再次失败。我们称这样的测试为“片状”。

A test can fail inconsistently. Even if the production code under test hasn’t changed, a test can suddenly fail without any apparent reason, then pass again, then fail again. We call a test like that “flaky.”

Flaky 测试是一种特殊的野兽,我将在 7.5 节中处理它们。

Flaky tests are a special beast, and I’ll deal with them in section 7.5.

7.3 避免单元测试中的逻辑

7.3 Avoiding logic in unit tests

当您在测试中包含越来越多的逻辑时,测试中出现错误的可能性几乎呈指数级增加。我见过很多本应简单的测试变成了动态的、随机数生成的、线程创建的、文件写入的怪物,它们本身就是小测试引擎。可悲的是,因为它们是“测试”,所以作者没有考虑到它们可能存在错误或没有以可维护的方式编写它们。这些测试怪物花费的调试和验证时间比它们节省的时间还要多。

The chances of having bugs in your tests increase almost exponentially as you include more and more logic in them. I’ve seen plenty of tests that should have been simple become dynamic, random-number-generating, thread-creating, file-writing monsters that are little test engines in their own right. Sadly, because they were “tests,” the writer didn’t consider that they might have bugs or didn’t write them in a maintainable manner. Those test monsters take more time to debug and verify than they save.

但所有的怪物都是从小开始的。通常,公司中经验丰富的开发人员会查看测试并开始思考:“如果我们让函数循环并创建随机数作为输入怎么办?这样我们肯定会发现更多的错误!” 你会的,尤其是在测试中。

But all monsters start out small. Often, an experienced developer in the company will look at a test and start thinking, “What if we made the function loop and create random numbers as input? We’d surely find lots more bugs that way!” And you will, especially in your tests.

测试错误是开发人员最烦人的事情之一,因为您几乎永远不会在测试本身中寻找失败测试的原因。我并不是说逻辑测试没有任何价值。事实上,在某些特殊情况下我可能会自己编写这样的测试。但我尽量避免这种做法。

Test bugs are one of the most annoying things for developers, because you’ll almost never search for the cause of a failing test in the test itself. I’m not saying that tests with logic don’t have any value. In fact, I’m likely to write such tests myself in some special situations. But I try to avoid this practice as much as possible.

如果单元测试中有以下任何内容,则您的测试包含我通常建议减少或完全删除的逻辑:

If you have any of the following inside a unit test, your test contains logic that I usually recommend be reduced or removed completely:

  • switchif、 或else语句

  • switch, if, or else statements

  • foreachfor、 或while循环

  • foreach, for, or while loops

  • 连接(+ 号等)

  • Concatenations (+ sign, etc.)

  • try,catch

  • try, catch

7.3.1 断言中的逻辑:创建动态期望值

7.3.1 Logic in asserts: Creating dynamic expected values

下面是一个串联的简单示例,供我们开始。

Here’s a quick example of a concatenation to start us off.

清单 7.1 一个包含逻辑的测试

Listing 7.1 A test with logic in it

描述(“makeGreeting”,()=> {
  it("返回正确的姓名问候语", () => {
    常量名称=“abc”;
    const 结果 = trust.makeGreeting(name);
    Expect(结果).toBe(“你好”+名字);        
  });
describe("makeGreeting", () => {
  it("returns correct greeting for name", () => {
    const name = "abc";
    const result = trust.makeGreeting(name);
    expect(result).toBe("hello" + name);      
  });

断言部分的逻辑

Logic in the assertion part

为了了解此测试的问题,以下清单显示了正在测试的代码。请注意,该+标志同时出现在两者中。

To understand the problem with this test, the following listing shows the code being tested. Notice that the + sign makes an appearance in both.

清单 7.2 被测代码

Listing 7.2 Code under test

const makeGreeting = (name) => {
   return "hello" + name; }          
};
const makeGreeting = (name) => {
  return "hello" + name;         
};

与生产代码中的逻辑相同

The same logic as in the production code

请注意连接名称与字符串的算法(非常简单,但仍然是一个算法)如何"hello"在测试和被测代码中重复:

Notice how the algorithm (very simple, but still an algorithm) of connecting a name with a "hello" string is repeated in both the test and the code under test:

Expect(结果).toBe(“你好”+名字);   
返回“你好”+名字;                 
expect(result).toBe("hello" + name);   
return "hello" + name;                 

我们的测试

Our test

待测试代码

The code under test

我对这个测试的问题是被测算法在测试本身中重复。这意味着如果算法中存在错误,则测试也包含相同的错误。测试不会捕获错误,而是期望被测试的代码得到不正确的结果。

My issue with this test is that the algorithm under test is repeated in the test itself. This means that if there is a bug in the algorithm, the test also contains the same bug. The test will not catch the bug, but instead will expect the incorrect result from the code under test.

在这种情况下,错误的结果是我们在连接的单词之间缺少空格字符,但希望您可以看到使用更复杂的算法如何使同一问题变得更加复杂。

In this case, the incorrect result is that we’re missing a space character between the concatenated words, but hopefully you can see how the same issue could become much more complex with a more complex algorithm.

这是一个信任问题。我们不能相信这个测试会告诉我们真相,因为它的逻辑是正在测试的逻辑的重复。当代码中存在错误时,测试可能会通过,因此我们不能相信测试的结果。

This is a trust issue. We can’t trust this test to tell us the truth, since its logic is a repeat of the logic being tested. The test might pass when the bug exists in the code, so we can’t trust the test’s result.

警告避免在断言中动态创建预期值;尽可能使用硬编码值。

Warning Avoid dynamically creating the expected value in your asserts; use hardcoded values when possible.

该测试的更值得信赖的版本可以重写如下。

A more trustworthy version of this test can be rewritten as follows.

清单 7.3 更值得信赖的测试

Listing 7.3 A more trustworthy test

it("返回名字 2 的正确问候语", () => {
  const 结果 = trust.makeGreeting("abc");
  期望(结果).toBe( “你好abc” );            
});
it("returns correct greeting for name 2", () => {
  const result = trust.makeGreeting("abc");
  expect(result).toBe("hello abc");           
});

使用硬编码值

Using a hardcoded value

由于此测试中的输入非常简单,因此很容易编写硬编码的期望值。这是我通常建议的做法——使测试输入如此简单,以便创建预期值的硬编码版本。请注意,单元测试大多如此。对于更高级别的测试,这有点难做到,这也是为什么更高级别的测试应该被认为风险更大的另一个原因;它们经常动态地创建预期结果,您应该尽可能避免这种情况。

Because the inputs in this test are so simple, it’s easy to write a hardcoded expected value. This is what I usually recommend—make the test inputs so simple that it is trivial to create a hardcoded version of the expected value. Note that this is mostly true of unit tests. For higher-level tests, this is a bit harder to do, which is another reason why higher-level tests should be considered a bit riskier; they often create expected results dynamically, which you should try to avoid any time you can.

“但是罗伊,”你可能会说,“现在我们正在重复自己——字符串"abc"重复了两次。在之前的测试中我们能够避免这种情况。” 当紧要关头,信任应该胜过可维护性。我不能信任的高度可维护的测试有什么用呢?您可以在 Vladimir Khorikov 的文章“单元测试中的 DRY 与 DAMP”( https://enterprisecraftsmanship.com/posts/dry-damp-unit-tests/ ) 中了解有关测试中代码重复的更多信息。

“But Roy,” you might say, “Now we are repeating ourselves—the string "abc" is repeated twice. We were able to avoid this in the previous test.” When push comes to shove, trust should trump maintainability. What good is a highly maintainable test that I cannot trust? You can read more about code duplication in tests in Vladimir Khorikov’s article, “DRY vs. DAMP in Unit Tests,” (https://enterprisecraftsmanship.com/posts/dry-damp-unit-tests/).

7.3.2 其他形式的逻辑

7.3.2 Other forms of logic

这是相反的情况:动态创建输入(使用循环)迫使我们动态决定预期输出应该是什么。假设我们有以下代码要测试。

Here’s the opposite case: creating the inputs dynamically (using a loop) forces us to dynamically decide what the expected output should be. Suppose we have the following code to test.

清单 7.4 名称查找函数

Listing 7.4 A name-finding function

const isName = (输入) => {
  return input.split(" ").length === 2;
};
const isName = (input) => {
  return input.split(" ").length === 2;
};

以下清单显示了测试的清晰反模式。

The following listing shows a clear antipattern for a test.

清单 7.5 测试中的循环和 if

Listing 7.5 Loops and ifs in a test

描述(“isName”,()=> {
   const namesToTest = [“firstOnly”,“第一第二”,“”];    
 
  it("正确判断它是否是一个名字", () => {
    nameToTest.forEach((名称) => {
      const 结果 = trust.isName(name);
      if (name.includes(" ")) {                              
        Expect(结果).toBe(true);                          
      } else {                                              
        期望(结果).toBe(假);                        
      }
    });
  });
});
describe("isName", () => {
  const namesToTest = ["firstOnly", "first second", ""];   
 
  it("correctly finds out if it is a name", () => {
    namesToTest.forEach((name) => {
      const result = trust.isName(name);
      if (name.includes(" ")) {                            
        expect(result).toBe(true);                         
      } else {                                             
        expect(result).toBe(false);                        
      }
    });
  });
});

声明多个输入

Declaring multiple inputs

生产代码逻辑泄漏到测试中

Production code logic leaking into the test

请注意我们如何使用多个输入进行测试。这迫使我们循环这些输入,这本身就使测试变得复杂。请记住,循环也可能有错误。

Notice how we’re using multiple inputs for the test. This forces us to loop over those inputs, which in itself complicates the test. Remember, loops can have bugs too.

此外,因为我们对值有不同的场景(带空格和不带空格),所以我们需要一个if/else来了解断言所期望的内容,并且if/else也可能有错误。我们还重复了生产算法的一部分,这让我们回到了之前的串联示例及其问题。

Additionally, because we have different scenarios for the values (with and without spaces) we need an if/else to know what the assertion is expecting, and the if/else can have bugs too. We are also repeating a part of the production algorithm, which brings us back to the previous concatenation example and its problems.

最后,我们的测试名称太通用了。我们只能将其称为“它有效”,因为我们必须考虑多种场景和预期结果。这对可读性不利。

Finally, our test name is too generic. We can only title it as “it works” because we have to account for multiple scenarios and expected outcomes. That’s bad for readability.

这是一次全面的糟糕测试。最好将其分成两个或三个测试,每个测试都有自己的场景和名称。这将允许我们使用硬编码的输入和断言,并从代码中删除任何循环和if/逻辑。else任何更复杂的情况都会导致以下问题:

This is an all-around bad test. It’s better to separate this into two or three tests, each with its own scenario and name. This would allow us to use hardcoded inputs and assertions and to remove any loops and if/else logic from the code. Anything more complex causes the following problems:

  • 该测试更难阅读和理解。

  • The test is harder to read and understand.

  • 测试很难重现。例如,想象一个多线程测试或使用随机数的测试突然失败。

  • The test is hard to recreate. For example, imagine a multithreaded test or a test with random numbers that suddenly fails.

  • 测试更有可能出现错误或验证错误的事情。

  • The test is more likely to have a bug or to verify the wrong thing.

  • 为测试命名可能会更困难,因为它做了很多事情。

  • Naming the test may be harder because it does multiple things.

一般来说,怪物测试会取代原来的简单测试,这使得在生产代码中发现错误变得更加困难。如果您必须创建一个怪物测试,则应将其添加为新测试,而不是替代现有测试。此外,它应该驻留在一个明确标题为保存单元测试以外的测试的项目或文件夹中。我将这些称为“集成测试”或“复杂测试”,并尝试将其数量保持在可接受的最低限度。

Generally, monster tests replace original simpler tests, and that makes it harder to find bugs in the production code. If you must create a monster test, it should be added as a new test and not be a replacement for existing tests. Also, it should reside in a project or folder explicitly titled to hold tests other than unit tests. I call these “integration tests” or “complex tests” and try to keep their number to an acceptable minimum.

7.3.3 更多逻辑

7.3.3 Even more logic

逻辑不仅可以在测试中找到,还可以在测试辅助方法、手写假货和测试实用程序类中找到。请记住,您在这些位置添加的每一段逻辑都会使代码更难以阅读,并增加测试使用的实用程序方法中出现错误的机会。

Logic can be found not only in tests but also in test helper methods, handwritten fakes, and test utility classes. Remember, every piece of logic you add in these places makes the code that much harder to read and increases the chances of a bug in a utility method that your tests use.

如果您发现由于某种原因需要在测试套件中包含复杂的逻辑(尽管这通常是我在集成测试中所做的事情,而不是单元测试),至少确保您对实用程序方法的逻辑进行了一些测试在测试项目中。这会让你在路上省去很多眼泪。

If you find that you need to have complicated logic in your test suite for some reason (though that’s generally something I do with integration tests, not unit tests), at least make sure you have a couple of tests against the logic of your utility methods in the test project. This will save you many tears down the road.

7.4 在通过测试时嗅到错误的信任感

7.4 Smelling a false sense of trust in passing tests

我们现在已经介绍了失败的测试,作为检测我们不应该信任的测试的方法。我们遍布各地的那些安静、绿色的测试怎么样?我们应该相信他们吗?在将测试推入主分支之前,我们需要对其进行代码审查,那又如何呢?我们应该寻找什么?

We’ve now covered failed tests as a means of detecting tests we shouldn’t trust. What about all those quiet, green tests we have lying all over the place? Should we trust them? What about a test that we need to do a code review for, before it’s pushed into a main branch? What should we look for?

让我们使用术语“错误信任”来描述您确实不应该信任但您还不知道的测试。能够审查测试并发现可能的错误信任问题具有巨大的价值,因为您不仅可以自己修复这些测试,而且还会影响其他所有要阅读或运行这些测试的人的信任。以下是我降低对测试信任度的一些原因,即使它们通过了:

Let’s use the term “false-trust” to describe trusting a test that you really shouldn’t, but you don’t know it yet. Being able to review tests and find possible false-trust issues has immense value because, not only can you fix those tests yourself, you’re affecting the trust of everyone else who’s ever going to read or run those tests. Here are some reasons I reduce my trust in tests, even if they are passing:

  • 该测试不包含断言。

  • The test contains no asserts.

  • 我无法理解这个测试。

  • I can’t understand the test.

  • 单元测试与片状集成测试混合在一起。

  • Unit tests are mixed with flaky integration tests.

  • 该测试验证多个关注点或退出点。

  • The test verifies multiple concerns or exit points.

  • 测试不断变化。

  • The test keeps changing.

7.4.1 不断言任何内容的测试

7.4.1 Tests that don’t assert anything

我们都同意,不能真正验证某件事是真是假的测试没有什么帮助,对吗?没什么帮助,因为它还会花费维护时间、重构和阅读时间,如果由于生产代码中的 API 更改而需要更改,有时还会产生不必要的噪音。

We all agree that a test that doesn’t actually verify that something is true or false is less than helpful, right? Less than helpful because it also costs in maintenance time, refactoring, and reading time, and sometimes unnecessary noise if it needs changing due to API changes in production code.

如果您看到没有断言的测试,请考虑函数调用中可能存在隐藏的断言。如果函数没有被命名来解释这一点,这会导致可读性问题。有时,人们还编写一个测试来执行一段代码,只是为了确保代码不会引发异常。这确实有一定的价值,如果这是您选择编写的测试,请确保测试的名称使用诸如“不抛出”之类的术语来表明这一点。更具体地说,许多测试 API 支持指定某些内容不引发异常的功能。您可以在 Jest 中执行此操作:

If you see a test with no asserts, consider that there may be hidden asserts in a function call. This causes a readability problem if the function is not named to explain this. Sometimes people also write a test that exercises a piece of code simply to make sure that the code does not throw an exception. This does have some value, and if that’s the test you choose to write, make sure that the name of the test indicates this with a term such as “does not throw.” To be even more specific, many test APIs support the ability to specify that something does not throw an exception. This is how you can do this in Jest:

Expect(() => someFunction()).not.toThrow(错误)
expect(() => someFunction()).not.toThrow(error)

如果确实有此类测试,请确保其数量非常少。我不建议将其作为标准,但仅适用于非常特殊的情况。

If you do have such tests, make sure there’s a very small number of them. I don’t recommend it as a standard, but only for really special cases.

有时,人们只是由于缺乏经验而忘记写断言。考虑添加缺少的断言或删除测试(如果它没有带来任何价值)。人们还可能积极编写测试来实现管理层设定的一些想象的测试覆盖率目标。这些测试通常没有任何实际价值,只是让人们摆脱管理层的束缚,以便他们能够做真正的工作。

Sometimes people simply forget to write an assert due to lack of experience. Consider adding the missing assert or removing the test if it brings no value. People may also actively write tests to achieve some imagined test coverage goal set by management. Those tests usually serve no real value except to get management off people’s backs so they can do real work.

提示代码覆盖率本身不应该成为一个目标。它并不意味着“代码质量”。事实上,它经常导致开发人员编写无意义的测试,这将花费更多的时间来维护。相反,衡量“逃逸的错误”、“修复时间”以及我们将在第 11 章中讨论的其他指标。

TIP Code coverage shouldn’t ever be a goal on its own. It doesn’t mean “code quality.” In fact, it often causes developers to write meaningless tests that will cost even more time to maintain. Instead, measure “escaped bugs,” “time to fix,” and other metrics that we’ll discuss in chapter 11.

7.4.2 不理解测试

7.4.2 Not understanding the tests

这是一个很大的问题,我将在第 9 章中深入讨论它。有几个可能的问题:

This is a huge issue, and I’ll deal with it in depth in chapter 9. There are several possible issues:

  • 使用坏名字进行测试

  • Tests with bad names

  • 测试太长或代码复杂

  • Tests that are too long or have convoluted code

  • 包含令人困惑的变量名称的测试

  • Tests containing confusing variable names

  • 包含不易理解的隐藏逻辑或假设的测试

  • Tests containing hidden logic or assumptions that cannot be understood easily

  • 测试结果不确定(既没有失败也没有通过)

  • Test results that are inconclusive (neither failed nor passed)

  • 测试未提供足够信息的消息

  • Test messages that don’t provide enough information

如果您不了解未通过或通过的测试,您就不知道是否应该担心。

If you don’t understand the test that’s failing or passing, you don’t know if you should be worried or not.

7.4.3 混合单元测试和片状集成测试

7.4.3 Mixing unit tests and flaky integration tests

他们说一个烂苹果毁了一堆苹果。对于与非片状测试混合的片状测试也是如此。集成测试比单元测试更有可能不稳定,因为它们具有更多的依赖性。如果您发现同一文件夹或测试执行命令中混合有集成测试和单元测试,您应该感到怀疑。

They say that one rotten apple spoils the bunch. The same is true for flaky tests mixed in with nonflaky tests. Integration tests are much more likely to be flaky than unit tests because they have more dependencies. If you find that you have a mix of integration and unit tests in the same folder or test execution command, you should be suspicious.

人类喜欢走阻力最小的路,在编码方面也不例外。假设开发人员运行了所有测试,其中一个测试失败了,如果有一种方法可以将其归咎于缺少配置或网络问题,而不是花时间调查和解决实际问题,那么他们就会这么做。如果他们面临严重的时间压力,或者他们过度致力于交付已经迟到的事情,则尤其如此。

Humans like to take the path of least resistance, and it’s no different when it comes to coding. Suppose that a developer runs all the tests and one of them fails—if there’s a way to blame a missing configuration or a network issue instead of spending time investigating and fixing a real problem, they will. That’s especially true if they’re under serious time pressure or they’re overcommitted to delivering things they’re already late on.

最简单的事情就是指责任何失败的测试是不稳定的测试。因为片状和非片状测试相互混合,所以这是一件简单的事情,也是忽略问题并从事更有趣的事情的好方法。由于这种人为因素,最好删除将不可靠的测试归咎于该选项。你应该做什么来防止这种情况发生?通过将集成和单元测试放在不同的地方来建立一个安全的绿色区域。

The easiest thing is to accuse any failing test of being a flaky test. Because flaky and nonflaky tests are mixed up with each other, that’s a simple thing to do, and it’s a good way to ignore the issue and work on something more fun. Because of this human factor, it’s best to remove the option to blame a test for being flaky. What should you do to prevent this? Aim to have a safe green zone by keeping your integration and unit tests in separate places.

安全的绿色测试区域应该只包含非片状、快速的测试,开发人员知道他们可以获得最新的代码版本,他们可以运行该命名空间或文件夹中的所有测试,并且测试都应该是绿色的(假设没有对生产进行任何更改)代码)。如果安全绿色区域中的某些测试未通过,开发人员更有可能感到担忧。

A safe green test area should contain only nonflaky, fast tests, where developers know that they can get the latest code version, they can run all the tests in that namespace or folder, and the tests should all be green (given no changes to production code). If some tests in the safe green zone don’t pass, a developer is much more likely to be concerned.

这种分离的另一个好处是,开发人员更有可能更频繁地运行单元测试,因为没有集成测试,运行时间会更快。有一些反馈总比没有反馈好,对吧?自动构建管道应该负责运行开发人员无法或不会在本地计算机上运行的任何“缺失”反馈测试。

An added benefit to this separation is that developers are more likely to run the unit tests more often, now that the run time is faster without the integration tests. It’s better to have some feedback than no feedback, right? The automated build pipeline should take care of running any of the “missing” feedback tests that developers can’t or won’t run on their local machines.

7.4.4 测试多个出口点

7.4.4 Testing multiple exit points

退出(我也将其称为关注点)在第 1 章中进行了解释。它是工作单元的单个最终结果:返回值、系统状态更改或对第三方的调用目的。

An exit point (I’ll also refer to it as a concern) is explained in chapter 1. It’s a single end result from a unit of work: a return value, a change to system state, or a call to a third-party object.

这是一个具有两个退出点或两个关注点的函数的简单示例。它既返回一个值又触发一个传入的回调函数:

Here’s a simple example of a function that has two exit points, or two concerns. It both returns a value and triggers a passed-in callback function:

const 触发器 = (x, y, 回调) => {
  回调(“我被触发了”);
  返回 x + y;
};
const trigger = (x, y, callback) => {
  callback("I'm triggered");
  return x + y;
};

我们可以编写一个测试来同时检查这两个退出点。

We could write a test that checks both of these exit points at the same time.

清单 7.6 在同一测试中检查两个退出点

Listing 7.6 Checking two exit points in the same test

描述(“触发”,()=> {
  它(“有效”,()=> {
    const 回调 = jest.fn();
    const 结果 = 触发器(1, 2, 回调);
    期望(结果).toBe(3); 
    Expect(callback).toHaveBeenCalledWith("我被触发了");
  });
});
describe("trigger", () => {
  it("works", () => {
    const callback = jest.fn();
    const result = trigger(1, 2, callback);
    expect(result).toBe(3);
    expect(callback).toHaveBeenCalledWith("I'm triggered");
  });
});

在测试中测试多个关注点可能会适得其反的第一个原因是您的测试名称会受到影响。我将在第 9 章中讨论可读性,但这里有一个关于命名的快速说明:命名测试对于调试和文档目的都非常重要。我花了很多时间思考测试的好名字,而且我并不羞于承认这一点。

The first reason testing more than one concern in a test can backfire is that your test name suffers. I’ll discuss readability in chapter 9, but here’s a quick note on naming: naming tests is hugely important for both debugging and documentation purposes. I spend a lot of time thinking about good names for tests, and I’m not ashamed to admit it.

命名测试可能看起来是一项简单的任务,但如果您要测试多个事物,则为测试指定一个好名称以表明正在测试的内容是很困难的。通常,您最终会得到一个非常通用的测试名称,迫使读者阅读测试代码。当您只测试一个问题时,为测试命名很容易。但等等,还有更多。

Naming a test may seem like a simple task, but if you’re testing more than one thing, giving the test a good name that indicates what’s being tested is difficult. Often you end up with a very generic test name that forces the reader to read the test code. When you test just one concern, naming the test is easy. But wait, there’s more.

更令人不安的是,在大多数单元测试框架中,失败的断言会引发测试框架运行程序捕获的特殊类型的异常。当测试框架捕获该异常时,就意味着测试失败。大多数语言中的大多数异常在设计上都不会让代码继续执行。所以如果这条线,

More disturbingly, in most unit test frameworks, a failed assert throws a special type of exception that’s caught by the test framework runner. When the test framework catches that exception, it means the test has failed. Most exceptions in most languages, by design, don’t let the code continue. So if this line,

期望(结果).toBe(3);
expect(result).toBe(3);

断言失败,这一行根本不会执行:

fails the assert, this line will not execute at all:

Expect(callback).toHaveBeenCalledWith("我被触发了");
expect(callback).toHaveBeenCalledWith("I'm triggered");

测试方法在抛出异常的同一行退出。这些断言中的每一个都可以而且应该被视为不同的需求,并且它们也可以(并且在这种情况下很可能应该)一个接一个地单独且增量地实现。

The test method exits on the same line where the exception is thrown. Each of these asserts can and should be considered different requirements, and they can also, and in this case likely should, be implemented separately and incrementally, one after the other.

将断言失败视为疾病的症状。您发现的症状越多,疾病就越容易诊断。失败后,后续断言不会执行,并且您将错过其他可能的症状,这些症状可以提供有价值的数据(症状),帮助您缩小焦点并发现根本问题。在单个单元测试中检查多个问题会增加复杂性,但价值不大。您应该在单独的、独立的单元测试中运行额外的关注点检查,以便您可以看到真正失败的地方。

Consider assert failures as symptoms of a disease. The more symptoms you can find, the easier the disease will be to diagnose. After a failure, subsequent asserts aren’t executed, and you’ll miss seeing other possible symptoms that could provide valuable data (symptoms) that would help you narrow your focus and discover the underlying problem. Checking multiple concerns in a single unit test adds complexity with little value. You should run additional concern checks in separate, self-contained unit tests so that you can see what really fails.

让我们将其分成两个单独的测试。

Let’s break it up into two separate tests.

清单 7.7 在单独的测试中检查两个退出点

Listing 7.7 Checking the two exit points in separate tests

描述(“触发”,()=> {
  it("触发给定的回调", () => {
    const 回调 = jest.fn();
    触发(1, 2, 回调);
    Expect(callback).toHaveBeenCalledWith("我被触发了");
  });
 
  it("对给定值求和", () => {
    const 结果 = 触发器(1, 2, jest.fn());
    期望(结果).toBe(3);
  });
});
describe("trigger", () => {
  it("triggers a given callback", () => {
    const callback = jest.fn();
    trigger(1, 2, callback);
    expect(callback).toHaveBeenCalledWith("I'm triggered");
  });
 
  it("sums up given values", () => {
    const result = trigger(1, 2, jest.fn());
    expect(result).toBe(3);
  });
});

现在我们可以清楚地分离这些关注点,并且每个关注点都可以单独失败。

Now we can clearly separate the concerns, and each one can fail separately.

有时,在同一个测试中断言多个事物是完全可以的,只要它们不是多个关注点即可。以以下函数及其相关测试为例。makePerson旨在构建person具有某些属性的新对象。

Sometimes it’s perfectly okay to assert multiple things in the same test, as long as they are not multiple concerns. Take the following function and its associated test as an example. makePerson is designed to build a new person object with some properties.

清单 7.8 使用多个断言来验证单个退出点

Listing 7.8 Using multiple asserts to verify a single exit point

const makePerson = (x, y) => {
  返回 {
    姓名:x,
    年龄: 岁,
    类型:“人”,
  };
};
 
描述(“makePerson”,()=> {
  it("根据传入的值创建人", () => {
    const 结果 = makePerson("姓名", 1);
    期望(结果.名称).toBe(“名称”); 
    期望(结果.年龄).toBe(1);
  });
});
const makePerson = (x, y) => {
  return {
    name: x,
    age: y,
    type: "person",
  };
};
 
describe("makePerson", () => {
  it("creates person given passed in values", () => {
    const result = makePerson("name", 1);
    expect(result.name).toBe("name");
    expect(result.age).toBe(1);
  });
});

在我们的测试中,我们同时断言姓名和年龄,因为它们是同一关注点(构建对象person)的一部分。如果第一个断言失败,我们可能不关心第二个断言,因为在构建对象时可能会出现严重错误。

In our test, we are asserting on both name and age together, because they are part of the same concern (building the person object). If the first assert fails, we likely don’t care about the second assert because something might have gone terribly wrong while building the object in the first place.

提示这里有一个测试分解提示:如果第一个断言失败,你还关心下一个断言的结果是什么吗?如果这样做,您可能应该将测试分成两个测试。

Tip Here’s a test break-up hint: If the first assert fails, do you still care what the result of the next assert is? If you do, you should probably separate the test into two tests.

7.4.5 不断变化的测试

7.4.5 Tests that keep changing

如果测试使用当前日期和时间作为其执行或断言的一部分,那么我们可以声明每次测试运行时都是不同的测试。对于使用随机数、机器名称或任何依赖于从测试环境外部获取当前值的内容的测试也可以这样说。其结果很可能不一致,这意味着结果可能不稳定。对于我们作为开发人员来说,不稳定的测试会降低我们对测试失败结果的信任(我将在下一节中讨论)。

If a test is using the current date and time as part of its execution or assertions, then we can claim that every time the test runs, it’s a different test. The same can be said of tests that use random numbers, machine names, or anything that depends on grabbing a current value from outside the test’s environment. There’s a big chance its results won’t be consistent, and that means they can be flaky. For us, as developers, flaky tests reduce our trust in the failed results of the test (as I’ll discuss in the next section).

动态生成值的另一个巨大的潜在问题是,如果我们提前不知道系统的输入可能是什么,我们还必须计算系统的预期输出,这可能会导致错误的测试,该测试取决于关于重复生产逻辑,如第 7.3 节所述。

Another huge potential issue with dynamically generated values is that if we don’t know ahead of time what the input into the system might be, we also have to compute the expected output of the system, and that can lead to a buggy test that depends on repeating production logic, as mentioned in section 7.3.

7.5 处理片状测试

7.5 Dealing with flaky tests

我不确定是谁提出了“片状测试”这个术语,但它确实符合要求。它用于描述在不更改代码的情况下返回不一致结果的测试。这种情况可能经常发生或很少发生,但它确实发生了。

I’m not sure who came up with the term flaky tests, but it does fit the bill. It’s used to describe tests that, given no changes to the code, return inconsistent results. This might happen frequently or very rarely, but it does happen.

图 7.1 说明了片状现象的来源。该数字基于测试所具有的实际依赖关系的数量。另一种思考方式是测试有多少活动部件。对于本书,我们主要关注该图的底部三分之一:单元和组件测试。然而,我想谈谈更高层次的脆弱性,这样我就可以给你一些关于研究内容的指导。

Figure 7.1 illustrates where flakiness comes from. The figure is based on the number of real dependencies the tests have. Another way to think about this is how many moving parts the tests have. For this book, we’re mostly concerning ourselves with the bottom third of this diagram: unit and component tests. However, I want to touch on the higher-level flakiness so I can give you some pointers on what to research.

07-01



图 7.1 测试级别越高,它们使用的真实依赖关系就越多,这让我们对整个系统的正确性充满信心,但会导致更多的不稳定。

Figure 7.1 The higher the level of the tests, the more real dependencies they use, which gives us confidence in the overall system correctness but results in more flakiness.

在最低级别,我们的测试可以完全控制它们的所有依赖项,因此没有移动部件,要么是因为它们是伪造的,要么是因为它们纯粹在内存中运行并且可以配置。我们在第 3 章和第 4 章中做到了这一点。代码中的执行路径是完全确定的,因为所有初始状态和各种依赖项的预期返回值都已预先确定。代码路径几乎是静态的——如果它返回错误的预期结果,那么生产代码的执行路径或逻辑中可能发生了一些重要的变化。

At the lowest level, our tests have full control over all of their dependencies and therefore have no moving parts, either because they’re faking them or because they run purely in memory and can be configured. We did this in chapters 3 and 4. Execution paths in the code are fully deterministic because all the initial states and expected return values from various dependencies have been predetermined. The code path is almost static—if it returns the wrong expected result, then something important might have changed in the production code’s execution path or logic.

随着级别的提升,我们的测试会放弃越来越多的桩和模拟,并开始使用越来越多的真实依赖项,例如数据库、网络、配置等。反过来,这意味着我们无法控制更多的移动部分,并且可能会改变我们的执行路径,返回意外的值,或者根本无法执行。

As we go up the levels, our tests shed more and more stubs and mocks and start using more and more real dependencies, such as databases, networks, configuration, and more. This, in turn, means more moving parts that we have less control over and that might change our execution path, return unexpected values, or fail to execute at all.

在最高级别,不存在虚假依赖项。我们的测试所依赖的一切都是真实的,包括任何第三方服务、安全和网络层以及配置。这些类型的测试通常要求我们设置一个尽可能接近生产场景的环境,如果它们不能在生产环境上正确运行的话。

At the highest level, there are no fake dependencies. Everything our tests rely on is real, including any third-party services, security and network layers, and configuration. These types of tests usually require us to set up an environment that is as close to a production scenario as possible, if they’re not running right on the production environments.

测试图中的位置越高,我们对代码工作的信心就越高,除非我们不相信测试结果。不幸的是,我们在图表中走得越高,我们的测试就越有可能因为涉及到的移动部件而变得不稳定。

The higher up we go in the test diagram, we should get higher confidence that our code works, unless we don’t trust the tests’ results. Unfortunately, the higher up we go in the diagram, the more chances there are for our tests to become flaky because of how many moving parts are involved.

我们可能会假设最低级别的测试不应该有任何片状问题,因为不应该有任何导致片状的移动部件。这在理论上是正确的,但实际上人们仍然设法在较低级别的测试中添加移动部件:使用当前日期和时间、机器名称、网络、文件系统等可能会导致测试不稳定。

We might assume that tests at the lowest level shouldn’t have any flakiness issues because there shouldn’t be any moving parts that cause flakiness. That’s theoretically true, but in reality people still manage to add moving parts in lower-level tests: using the current date and time, the machine name, the network, the filesystem, and more can cause a test to be flaky.

有时,如果我们不接触生产代码,测试就会失败。例如:

A test fails sometimes without us touching production code. For example:

  • 每第三次运行就有一次测试失败。

  • A test fails every third run.

  • 测试每隔未知次数就会失败一次。

  • A test fails once every unknown number of times.

  • 当各种外部条件失败时,例如网络或数据库可用性、其他 API 不可用、环境配置等等,测试就会失败。

  • A test fails when various external conditions fail, such as network or database availability, other APIs not being available, environment configuration, and more.

更糟糕的是,测试使用的每个依赖项(网络、文件系统、线程等)通常都会增加测试运行的时间。调用网络和数据库需要时间。等待线程完成、读取配置和等待异步任务也是如此。

To add to that salad of pain, each dependency the test uses (network, filesystem, threads, etc.) usually adds time to the test run. Calls to the network and the database take time. The same goes for waiting for threads to finish, reading configurations, and waiting for asynchronous tasks.

找出测试失败的原因也需要更长的时间。调试测试或阅读大量日志非常耗时,并且会慢慢地将您的灵魂耗尽到“是时候更新我的简历”的深渊了。

It also takes longer to figure out why a test is failing. Debugging a test or reading through huge amounts of logs is heartbreakingly time consuming and will drain your soul slowly into the abyss of “time to update my resume” land.

7.5.1 一旦发现不稳定的测试,您可以做什么?

7.5.1 What can you do once you’ve found a flaky test?

重要的是要认识到不稳定的测试对于组织来说可能代价高昂。您应该将零不稳定测试作为长期目标。以下是一些降低处理片状测试相关成本的方法:

It’s important to realize that flaky tests can be costly to an organization. You should aim to have zero flaky tests as a long-term goal. Here are some ways to reduce the costs associated with handling flaky tests:

  • 定义——就“片状”对您的组织意味着什么达成一致。例如,在不更改任何生产代码的情况下运行测试套件 10 次,并计算结果不一致的所有测试(即未全部 10 次失败或未全部 10 次通过的测试)。

  • Define—Agree on what “flaky” means to your organization. For example, run your test suite 10 times without any production code changes, and count all the tests that were not consistent in their results (i.e., ones that did not fail all 10 times or did not pass all 10 times).

  • 将任何被认为不稳定的测试放在可以单独运行的测试的特殊类别或文件夹中。我建议从常规交付版本中删除所有不稳定的测试,这样它们就不会产生噪音,并暂时将它们隔离在自己的小管道中。然后,检查每个不稳定的测试并玩我最喜欢的不稳定游戏“修复、转换或杀死”:

    • 修复——如果可能的话,通过控制其依赖关系来使测试不不稳定。例如,如果需要数据库中的数据,则将数据插入数据库作为测试的一部分。

    • 转换— 通过删除和控制一个或多个依赖项,将测试转换为较低级别的测试,从而消除不稳定性。例如,使用桩模拟网络端点,而不是使用真实的网络端点。

    • Kill——认真考虑测试带来的价值是否足以继续运行它并支付它所产生的维护成本。有时,旧的片状测试最好死掉并埋葬。有时它们已经被更新、更好的测试覆盖,而旧的测试是我们可以摆脱的纯粹的技术债务。可悲的是,许多工程经理不愿意删除这些旧的测试,因为沉没成本谬误——投入了太多的精力,删除它们是一种浪费。然而,此时,保留测试的成本可能比删除测试的成本更高,因此我建议对于许多不稳定的测试认真考虑此选项。

  • Place any test deemed flaky in a special category or folder of tests that can be run separately. I recommend removing all flaky tests from the regular delivery build so they do not create noise, and quarantining them in their own little pipeline temporarily. Then, go over each of the flaky tests and play my favorite flaky game, “fix, convert, or kill”:

    • Fix—Make the test not flaky by controlling its dependencies, if possible. For example, if it requires data in the database, insert the data into the database as part of the test.

    • Convert—Remove flakiness by converting the test into a lower-level test by removing and controlling one or more of its dependencies. For example, simulate a network endpoint with a stub instead of using a real one.

    • Kill—Seriously consider whether the value the test brings is enough to continue to run it and pay the maintenance costs it creates. Sometimes old flaky tests are better off dead and buried. Sometimes they are already covered by newer, better tests, and the old tests are pure technical debt that we can get rid of. Sadly, many engineering managers are reluctant to remove these old tests because of the sunken cost fallacy—there was so much effort put into them that it would be a waste to delete them. However, at this point, it might cost you more to keep the test than to delete it, so I recommend seriously considering this option for many of your flaky tests.

7.5.2 防止更高级别测试中的不稳定

7.5.2 Preventing flakiness in higher-level tests

如果您有兴趣防止高级测试中的不稳定,那么最好的选择是确保您的测试在任何部署后都可以在任何环境中重复。这可能涉及以下内容:

If you’re interested in preventing flakiness in higher-level tests, your best bet is to make sure that your tests are repeatable on any environment after any deployment. That could involve the following:

  • 回滚您的测试对外部共享资源所做的任何更改。

  • Roll back any changes your tests have made to external shared resources.

  • 不要依赖其他测试来改变外部状态。

  • Do not depend on other tests changing external state.

  • 通过确保您能够随意重新创建外部系统和依赖项(在互联网上搜索“基础设施即代码”)、创建您可以控制的虚拟系统或在它们上创建特殊的测试帐户并祈祷,获得对外部系统和依赖项的一定控制确保他们保持安全。

  • Gain some control over external systems and dependencies by ensuring you have the ability to recreate them at will (do an internet search on “infrastructure as code”), creating dummies of them that you can control, or creating special test accounts on them and pray that they stay safe.

最后一点,在使用其他公司管理的外部系统时,控制外部依赖关系可能很困难或不可能。当这是真的时,值得考虑以下选项:

On this last point, controlling external dependencies can be difficult or impossible when using external systems managed by other companies. When that’s true, it’s worth considering these options:

  • 如果某些低级别测试已经涵盖了这些场景,则删除一些较高级别的测试。

  • Remove some of the higher-level tests if some low-level tests already cover those scenarios.

  • 将一些较高级别的测试转换为一组较低级别的测试。

  • Convert some of the higher-level tests to a set of lower-level tests.

  • 如果您正在编写新的测试,请考虑采用测试配方的管道友好型测试策略(例如我将在第 10 章中解释的策略)。

  • If you’re writing new tests, consider a pipeline-friendly testing strategy with test recipes (such as the one I’ll explain in chapter 10).

概括

Summary

  • 如果您在测试失败时不信任它,您可能会忽略真正的错误,如果您在测试通过时不信任它,您最终将进行大量手动调试和测试。这两种结果都应该通过良好的测试来减少,但如果我们不减少它们,并且我们花了所有时间编写我们不信任的测试,那么首先编写它们的意义何在?

  • If you don’t trust a test when it’s failing, you might ignore a real bug, and if you don’t trust a test when it’s passing, you’ll end up doing lots of manual debugging and testing. Both of these outcomes are supposed to be reduced by having good tests, but if we don’t reduce them, and we spend all this time writing tests that we don’t trust, what’s the point in writing them in the first place?

  • 测试可能会因多种原因而失败:生产代码中发现真正的错误、测试中导致错误失败的错误、由于功能更改而导致测试过时、测试与另一个测试冲突或测试不稳定。只有第一个原因是有效的。所有其他人都告诉我们测试不应该被信任。

  • Tests might fail for multiple reasons: a real bug found in production code, a bug in the test resulting in a false failure, a test being out of date due to a change in functionality, a test conflicting with another test, or test flakiness. Only the first reason is a valid one. All the others tell us the test shouldn’t be trusted.

  • 避免测试中的复杂性,例如创建动态期望值或从底层生产代码复制逻辑。这种复杂性增加了在测试中引入错误的机会以及理解它们所需的时间。

  • Avoid complexity in tests, such as creating dynamic expected values or duplicating logic from the underlying production code. Such complexity increases the chances of introducing bugs in tests and the time it takes to understand them.

  • 如果一个测试没有任何断言,你无法理解它在做什么,它与片状测试一起运行(即使这个测试本身不是片状的),它验证多个退出点,或者它不断变化,它可以'不能完全信任。

  • If a test doesn’t have any asserts, you can’t understand what's it’s doing, it runs alongside flaky tests (even if this test itself isn’t flaky), it verifies multiple exit points, or it keeps changing, it can’t be fully trusted.

  • Flaky 测试是不可预测地失败的测试。测试的级别越高,它使用的真实依赖关系就越多,这让我们对整个系统的正确性充满信心,但会导致更多的不稳定。为了更好地识别不稳定的测试,请将它们放在可以单独运行的特殊类别或文件夹中。

  • Flaky tests are tests that fail unpredictably. The higher the level of the test, the more real dependencies it uses, which gives us confidence in the overall system’s correctness but results in more flakiness. To better identify flaky tests, put them in a special category or folder that can be run separately.

  • 为了减少测试的不稳定,要么修复测试,要么将不稳定的高级测试转换为不太不稳定的低级测试,要么删除它们。

  • To reduce test flakiness, either fix the tests, convert flaky higher-level tests into less flaky lower-level ones, or delete them.

8 可维护性

8 Maintainability

本章涵盖

This chapter covers

  • 测试失败的根本原因
  • Root causes of failing tests
  • 对测试代码常见的可避免的更改
  • Common avoidable changes to test code
  • 提高当前未失败的测试的可维护性
  • Improving the maintainability of tests that aren’t currently failing

测试可以让我们更快地开发,除非它们因为所有需要的改变而让我们进展得更慢。如果我们能够在更改生产代码时避免更改现有测试,我们就可以开始希望我们的测试能够帮助而不是损害我们的底线。在本章中,我们将重点关注测试的可维护性。

Tests can enable us to develop faster, unless they make us go slower due to all the changes needed. If we can avoid changing existing tests when we change production code, we can start to hope that our tests are helping rather than hurting our bottom line. In this chapter, we’ll focus on the maintainability of tests.

无法维护的测试可能会破坏项目进度,并且当项目进度更加紧迫时,测试通常会被搁置。开发人员将停止维护和修复那些需要很长时间才能更改或由于非常小的生产代码更改而需要经常更改的测试。

Unmaintainable tests can ruin project schedules and are often set aside when the project is put on a more aggressive schedule. Developers will simply stop maintaining and fixing tests that take too long to change or that need to change often as the result of very minor production code changes.

如果可维护性是衡量我们被迫更改测试的频率的标准,那么我们希望尽量减少发生这种情况的次数。如果我们想找出根本原因,这迫使我们提出这些问题:

If maintainability is a measure of how often we are forced to change tests, we’d like to minimize the number of times that happens. This forces us to ask these questions if we ever want to get down to the root causes:

  • 我们什么时候注意到测试失败并因此可能需要更改?

  • When do we notice that a test fails and therefore might require a change?

  • 为什么测试会失败?

  • Why do tests fail?

  • 哪些测试失败迫使我们改变测试?

  • Which test failures force us to change the test?

  • 即使我们没有被迫,我们什么时候会选择更改测试?

  • When do we choose to change a test even if we are not forced to?

本章介绍了一系列与可维护性相关的实践,您可以在进行测试评审时使用它们。

This chapter presents a series of practices related to maintainability that you can use when doing test reviews.

8.1 由于测试失败而强制进行的更改

8.1 Changes forced by failing tests

失败的测试通常是可维护性出现潜在问题的第一个迹象。当然,我们可以在生产代码中发现真正的错误,但如果情况并非如此,测试失败还有什么其他原因呢?我将把真正的故障称为真正的故障,将由于在底层生产代码中发现错误以外的原因发生的故障称为假故障

A failing test is usually the first sign of potential trouble for maintainability. Of course, we could have found a real bug in production code, but when that’s not the case, what other reasons do tests have to fail? I’ll refer to genuine failures as true failures, and failures that happen for reasons other than finding a bug in the underlying production code as false failures.

如果我们想要衡量测试的可维护性,我们可以首先衡量随着时间的推移错误测试失败的数量以及每次失败的原因。我们已经在第 7 章中讨论了这样一个原因:当测试包含错误时。现在让我们讨论错误失败的其他可能原因。

If we wanted to measure test maintainability, we could start by measuring the number of false test failures, and the reason for each failure, over time. We already discussed one such reason in chapter 7: when a test contains a bug. Let’s now discuss other possible reasons for false failures.

8.1.1 该测试与其他测试不相关或冲突

8.1.1 The test is not relevant or conflicts with another test

当生产代码引入与一个或多个现有测试直接冲突的新功能时,可能会出现冲突。测试可能不会发现错误,而是会发现冲突或新的需求。还可能有一个针对生产代码应如何工作的新期望的通过测试。

A conflict may arise when the production code introduces a new feature that’s in direct conflict with one or more existing tests. Instead of the test discovering a bug, it may discover conflicting or new requirements. There might also be a passing test that targets the new expectation for how the production code should work.

要么现有的失败测试不再相关,要么新的要求是错误的。假设要求是正确的,您可以继续删除不再相关的测试。

Either the existing failing test is no longer relevant, or the new requirement is wrong. Assuming that the requirement is correct, you can probably go ahead and delete the no-longer-relevant test.

请注意,“删除测试”规则有一个常见的例外:当您使用功能切换时。当我们讨论测试策略时,我们将在第 10 章中讨论功能切换。

Note that there’s a common exception to the “remove the test” rule: when you’re working with feature toggles. We’ll touch on feature toggles in chapter 10 when we discuss testing strategies.

8.1.2 生产代码API的变化

8.1.2 Changes in the production code’s API

如果被测生产代码发生变化,导致被测试的函数或对象现在需要以不同的方式使用,即使它可能仍然具有相同的功能,测试也可能会失败。这种错误的失败属于“让我们尽可能避免这种情况”的情况。

A test can fail if the production code under test changes so that a function or object being tested now needs to be used differently, even though it may still have the same functionality. Such false failures fall in the bucket of “let’s avoid this as much as possible.”

考虑PasswordVerifier清单 8.1 中的类,它需要两个构造函数参数:

Consider the PasswordVerifier class in listing 8.1, which requires two constructor parameters:

  • 一个数组rules(每个都是一个接受输入并返回布尔值的函数)

  • An array of rules (each is a function that takes an input and returns a Boolean)

  • 一个ILogger接口

  • An ILogger interface

清单 8.1 具有两个构造函数参数的密码验证器

Listing 8.1 A Password Verifier with two constructor parameters

导出类密码验证器{
    ...
    构造函数(规则:((输入)=>布尔值)[],记录器:ILogger){
        this._rules = 规则;
        this._logger = 记录器;
    }
 
    ...
}
export class PasswordVerifier {
    ...
    constructor(rules: ((input) => boolean)[], logger: ILogger) {
        this._rules = rules;
        this._logger = logger;
    }
 
    ...
}

我们可以编写一些如下所示的测试。

We could write a couple of tests like the following.

清单 8.2 没有工厂函数的测试

Listing 8.2 Tests without factory functions

描述(“密码验证者1”,()=> {
  it("零规则通过", () => {
    const verifier = new PasswordVerifier([], { info: jest.fn() });   
    const result = verifier.verify("任意输入");
    期望(结果)。toBe(真);
  });
 
  it("因单个失败规则而失败", () => {
    const failedRule = (输入) => false;
    常量验证器 =
      新的PasswordVerifier([failingRule], { info: jest.fn() });       
    const result = verifier.verify("任意输入");
    期望(结果).toBe(假);
  });
});
describe("password verifier 1", () => {
  it("passes with zero rules", () => {
    const verifier = new PasswordVerifier([], { info: jest.fn() });   
    const result = verifier.verify("any input");
    expect(result).toBe(true);
  });
 
  it("fails with single failing rule", () => {
    const failingRule = (input) => false;
    const verifier = 
      new PasswordVerifier([failingRule], { info: jest.fn() });       
    const result = verifier.verify("any input");
    expect(result).toBe(false);
  });
});

使用代码现有的API进行测试

Test using the code’s existing API

如果我们从可维护性的角度来看这些测试,我们将来可能需要做出一些潜在的改变。

If we look at these tests from a maintainability point of view, there are several potential changes we will likely need to make in the future.

代码通常会存在很长时间

Code usually lives for a long time

考虑到您正在编写的代码将在代码库中保留至少 4-6 年,有时甚至十年。在那段时间里,设计发生PasswordVerifier变化的可能性有多大?即使是简单的事情,比如构造函数接受更多参数,或者参数类型发生变化,在更长的时间范围内也变得更有可能。

Consider that the code you’re writing will live in the codebase for at least 4-6 years and sometimes a decade. Over that time, what is the likelihood that the design of PasswordVerifier will change? Even simple things, like the constructor accepting more parameters, or the parameter types changing, become more likely over a longer timeframe.

让我们列出密码验证器将来可能发生的一些变化:

Let’s list a few changes that could happen to our Password Verifier in the future:

  • 我们可以在 的构造函数中添加或删除参数PasswordVerifier

  • We may add or remove a parameter in the constructor for PasswordVerifier.

  • 的参数之一PasswordVerifier可能会更改为不同的类型。

  • One of the parameters for PasswordVerifier may change to a different type.

  • 函数的数量ILogger或其签名可能会随着时间的推移而改变。

  • The number of ILogger functions or their signatures may change over time.

  • 使用模式发生了变化,因此我们不需要实例化 new PasswordVerifier,而只需直接使用其中的函数即可。

  • The usage pattern changes so we don’t need to instantiate a new PasswordVerifier, but just use the functions in it directly.

如果发生这些情况,我们需要更改多少测试?现在我们需要更改所有实例化的测试PasswordVerifier。我们可以阻止其中一些更改的需要吗?

If any of these things happen, how many tests would we need to change? Right now we’d need to change all the tests that instantiate PasswordVerifier. Could we prevent the need for some of these changes?

让我们假设未来已经到来,我们的担心已经成真——有人改变了生产代码的 API。假设构造函数签名已更改为 useIComplicatedLogger而不是ILogger,如下所示。

Let’s pretend the future is here and our fears have come true—someone changed the production code’s API. Let’s say the constructor signature has changed to use IComplicatedLogger instead of ILogger, as follows.

清单 8.3 构造函数中的重大更改

Listing 8.3 A breaking change in a constructor

导出类密码验证器2 {
  私人_规则:((输入:字符串)=>布尔)[];
  私人_logger:IComplicatedLogger;
 
  构造函数(规则:((输入)=>布尔值)[],
      记录器:IComplicatedLogger ) {
    this._rules = 规则;
    this._logger = 记录器;
  }
...
}
export class PasswordVerifier2 {
  private _rules: ((input: string) => boolean)[];
  private _logger: IComplicatedLogger;
 
  constructor(rules: ((input) => boolean)[], 
      logger: IComplicatedLogger) {
    this._rules = rules;
    this._logger = logger;
  }
...
}

就目前情况而言,我们必须更改任何直接实例化的测试PasswordVerifier

As it stands, we would have to change any test that directly instantiates PasswordVerifier.

工厂函数解耦被测对象的创建

Factory functions decouple creation of object under test

将来避免这种痛苦的一个简单方法是解耦或抽象出被测代码的创建,以便仅需要在集中位置处理对构造函数的更改。其唯一目的是创建和预配置对象实例的函数通常称为工厂函数或方法。更高级的版本(我们不会在这里介绍)是对象母模式

A simple way to avoid this pain in the future is to decouple or abstract away the creation of the code under test so that the changes to the constructor only need to be dealt with in a centralized location. A function whose sole purpose is to create and preconfigure an instance of an object is usually called a factory function or method. A more advanced version of this (which we won’t cover here) is the Object Mother pattern.

工厂功能可以帮助我们缓解这个问题。接下来的两个清单显示了我们如何在签名更改之前最初编写测试,以及在这种情况下如何轻松适应签名更改。在清单 8.4 中, 的创建PasswordVerifier已被提取到其自己的集中工厂函数中。我对它做了同样的事情fakeLogger——它现在也是使用它自己的单独的工厂函数创建的。如果将来发生我们之前列出的任何更改,我们只需要更改我们的工厂功能即可;通常不需要触及测试。

Factory functions can help us mitigate this issue. The next two listings show how we could have initially written the tests before the signature change, and how we could easily adapt to the signature change in that case. In listing 8.4, the creation of PasswordVerifier has been extracted into its own centralized factory function. I’ve done the same for the fakeLogger—it’s now also created using its own separate factory function. If any of the changes we listed before happens in the future, we’ll only need to change our factory functions; the tests will usually not need to be touched.

清单 8.4 重构工厂函数

Listing 8.4 Refactoring to factory functions

描述(“密码验证者1”,()=> {
  const makeFakeLogger = () => {
    返回 { 信息:jest.fn() };                          
  };
 
  const makePasswordVerifier = (
    规则:((输入) => 布尔值)[],
    fakeLogger: ILogger = makeFakeLogger()) => {
      返回新的PasswordVerifier(规则,fakeLogger);    
  };
 
  it("零规则通过", () => {
    const 验证器 = makePasswordVerifier([]);           
 
    const result = verifier.verify("任意输入");
 
    期望(结果)。toBe(真);
  });
describe("password verifier 1", () => {
  const makeFakeLogger = () => {
    return { info: jest.fn() };                          
  };
 
  const makePasswordVerifier = (
    rules: ((input) => boolean)[],
    fakeLogger: ILogger = makeFakeLogger()) => {
      return new PasswordVerifier(rules, fakeLogger);    
  };
 
  it("passes with zero rules", () => {
    const verifier = makePasswordVerifier([]);           
 
    const result = verifier.verify("any input");
 
    expect(result).toBe(true);
  });

创建 fakeLogger 的集中点

A centralized point for creating a fakeLogger

创建PasswordVerifier的集中点

A centralized point for creating a PasswordVerifier

使用工厂函数创建PasswordVerifier

Using the factory function to create PasswordVerifier

在下面的清单中,我根据签名更改重构了测试。请注意,更改不涉及更改测试,而仅涉及工厂功能。这就是我在实际项目中可以接受的可管理变更类型。

In the following listing, I’ve refactored the tests based on the signature change. Notice that the change doesn’t involve changing the tests, but only the factory functions. That’s the type of manageable change I can live with in a real project.

清单 8.5 重构工厂方法以适应新的签名

Listing 8.5 Refactoring factory methods to fit a new signature

描述(“密码验证器(ctor更改)”,()=> {
  const makeFakeLogger = () => {
    return Substitute.for<IComplicatedLogger>();
  };
 
  const makePasswordVerifier = (
    规则:((输入) => 布尔值)[],
    fakeLogger: IComplicatedLogger = makeFakeLogger()) => {
    返回新的PasswordVerifier2(规则,fakeLogger);
  };
 
  // 测试保持不变
});
describe("password verifier (ctor change)", () => {
  const makeFakeLogger = () => {
    return Substitute.for<IComplicatedLogger>();
  };
 
  const makePasswordVerifier = (
    rules: ((input) => boolean)[],
    fakeLogger: IComplicatedLogger = makeFakeLogger()) => {
    return new PasswordVerifier2(rules, fakeLogger);
  };
 
  // the tests remain the same
});

8.1.3 其他测试的变化

8.1.3 Changes in other tests

缺乏测试隔离是测试阻塞的一个重要原因——我在咨询和进行单元测试时已经看到了这一点。您应该记住的基本概念是,测试应该始终在自己的小世界中运行,与其他测试隔离,即使它们验证相同的功能。

A lack of test isolation is a huge cause of test blockage—I’ve seen this while consulting and working on unit tests. The basic concept you should keep in mind is that a test should always run in its own little world, isolated from other tests even if they verify the same functionality.

叫喊“失败”的测试

The test that cried “fail”

我参与的一个项目的单元测试表现得很奇怪,随着时间的推移,它们变得更加奇怪。测试会失败,然后突然连续几天通过。一天后,它会失败,看起来是随机的,而有时即使更改代码以删除或更改其行为,它也会通过。开发人员会互相告诉对方:“啊,没关系。如果有时它过去了,那就意味着它过去了。”

One project I was involved in had unit tests behaving strangely, and they got even stranger as time went on. A test would fail and then suddenly pass for a couple of days straight. A day later, it would fail, seemingly randomly, and other times it would pass even if code was changed to remove or change its behavior. It got to the point where developers would tell each other, “Ah, it’s OK. If it sometimes passes, that means it passes.”

经过适当调查,结果发现该测试调用了一个不同的(且不稳定的)测试作为其代码的一部分,当另一个测试失败时,它将破坏第一个测试。

Properly investigated, it turned out that the test was calling out a different (and flaky) test as part of its code, and when the other test failed, it would break the first test.

在花了一个月的时间尝试各种解决方法后,我们花了三天的时间才解决了这个问题。当我们最终让测试正常工作时,我们发现代码中有很多我们忽略的真正错误,因为测试有自己的错误和问题。即使在发展过程中,“狼来了”的男孩的故事仍然成立。

It took us three days to untangle the mess, after spending a month trying various workarounds for the situation. When we finally had the test working correctly, we discovered that we had a bunch of real bugs in our code that we were ignoring because the test had its own bugs and issues. The story of the boy who cried wolf holds true even in development.

当测试没有很好地隔离时,它们可能会互相踩到对方的脚趾,让你后悔决定尝试单元测试并承诺自己永远不会再这样做。我见过这种情况发生。开发人员不会费心在测试中寻找问题,因此当出现问题时,可能需要花费大量时间才能找出问题所在。最简单的症状就是我所说的“测试顺序受限”。

When tests aren’t isolated well, they can step on each other’s toes, making you regret deciding to try unit testing and promising yourself never again. I’ve seen this happen. Developers don’t bother looking for problems in the tests, so when there’s a problem, it can take a lot of time to find out what’s wrong. The easiest symptom is what I call “constrained test order.”

受约束的测试顺序

Constrained test order

当测试假设先前的测试首先执行或没有首先执行时,就会出现受约束测试顺序,因为它依赖于由其他测试设置或重置的某些共享状态。例如,如果一个测试更改了内存中的共享变量或数据库等某些外部资源,而另一个测试在第一个测试执行后依赖于该变量的值,则测试之间存在基于顺序的依赖关系。

A constrained test order happens when a test assumes that a previous test executed first, or did not execute first, because it relies on some shared state that is set up or reset by the other test. For example, if one test changes a shared variable in memory or some external resource like a database, and another test depends on that variable’s value after the first tests’ execution, we have a dependency between the tests based on order.

再加上大多数测试运行者不(也不会,也许不应该!)保证测试将按特定顺序运行的事实。这意味着,如果您今天运行所有测试,并在一周后使用新版本的测试运行器运行所有测试,则测试可能不会按照与以前相同的顺序运行。

Couple that with the fact that most test runners don’t (and won’t, and maybe shouldn’t!) guarantee that tests will run in a specific order. This means that if you ran all your tests today, and all your tests a week later with a new version of the test runner, the tests might not run in the same order as before.

08-01



图8.1 共享UserCache实例

Figure 8.1 A shared UserCache instance

为了说明这个问题,让我们看一个简单的场景。图 8.1 显示了SpecialApp使用UserCache对象的对象。用户缓存保存单个实例(单例),该实例作为应用程序的缓存机制共享,顺便说一句,也用于测试。清单 8.6 显示了 的实现SpecialApp、用户缓存和IUserDetails接口。

To illustrate the problem, let’s look at a simple scenario. Figure 8.1 shows a SpecialApp object that uses a UserCache object. The user cache holds a single instance (a singleton) that is shared as a caching mechanism for the application, and, incidentally, also for the tests. Listing 8.6 shows the implementation of SpecialApp, the user cache, and the IUserDetails interface.

清单 8.6 共享用户缓存和相关接口

Listing 8.6 A shared user cache and associated interfaces

导出接口 IUserDetails {
  键:字符串;
  密码:字符串;
}
 
导出接口 IUserCache {
  addUser(用户:IUserDetails): void;
  getUser(键:字符串);
  重置():无效;
}
导出类 UserCache实现 IUserCache {
  用户:对象= {};
  addUser(用户:IUserDetails): void {
    if (this.users[user.key] !== undefined) {
      throw new Error("用户已经存在");
    }
    this.user[用户.key] = 用户;
  }
 
  获取用户(键:字符串){
    返回 this.user[key];
  }
 
  重置():无效{
    this.users = {};
  }
}
让_cache:IUserCache;
导出函数 getUserCache() {
  如果(_cache ===未定义){
    _cache = 新的UserCache();
  }
  返回_缓存;
}
export interface IUserDetails {
  key: string;
  password: string;
}
 
export interface IUserCache {
  addUser(user: IUserDetails): void;
  getUser(key: string);
  reset(): void;
}
export class UserCache implements IUserCache {
  users: object = {};
  addUser(user: IUserDetails): void {
    if (this.users[user.key] !== undefined) {
      throw new Error("user already exists");
    }
    this.users[user.key] = user;
  }
 
  getUser(key: string) {
    return this.users[key];
  }
 
  reset(): void {
    this.users = {};
  }
}
let _cache: IUserCache;
export function getUserCache() {
  if (_cache === undefined) {
    _cache = new UserCache();
  }
  return _cache;
} 

以下清单显示了SpecialApp实现。

The following listing shows the SpecialApp implementation.

清单 8.7SpecialApp实现

Listing 8.7 The SpecialApp implementation

导出类 SpecialApp {
  登录用户(键:字符串,密码:字符串):布尔值{
    常量缓存:IUserCache = getUserCache();
    const findUser: IUserDetails = cache.getUser(key);
    if (foundUser?.password === pass) {
      返回真;
    }
    返回假;
  }
}
export class SpecialApp {
  loginUser(key: string, pass: string): boolean {
    const cache: IUserCache = getUserCache();
    const foundUser: IUserDetails = cache.getUser(key);
    if (foundUser?.password === pass) {
      return true;
    }
    return false;
  }
}

这是本示例的一个简单实现,因此不必担心SpecialApp太多。让我们看看测试。

This is a simplistic implementation for this example, so don’t worry about SpecialApp too much. Let’s look at the tests.

清单 8.8 需要按特定顺序运行的测试

Listing 8.8 Tests that need to run in a specific order

描述(“测试依赖性”,()=> {
  描述(“登录用户与登录用户”,()=> {
    test("无用户,登录失败", () => {
      const app = new SpecialApp();
      const 结果 = app.loginUser("a", "abc");    
      期望(结果).toBe(假);                  
    });
 
    test("每个用户只能缓存一次", () => { 
      getUserCache().addUser({                      
        键:“a”,
        密码:“abc”,
      });
 
      期望(()=>
        getUserCache().addUser({
          键:“a”,
          密码:“abc”,
        })
      ).toThrowError("已经存在");
    });
 
    test("用户存在,登录成功", () => {
      const app = new SpecialApp();
      const 结果 = app.loginUser("a", "abc");     
      Expect(结果).toBe(true);                   
    });
  });
});
describe("Test Dependence", () => {
  describe("loginUser with loggedInUser", () => {
    test("no user, login fails", () => {
      const app = new SpecialApp();
      const result = app.loginUser("a", "abc");    
      expect(result).toBe(false);                  
    });
 
    test("can only cache each user once", () => {
      getUserCache().addUser({                     
        key: "a",
        password: "abc",
      });
 
      expect(() =>
        getUserCache().addUser({
          key: "a",
          password: "abc",
        })
      ).toThrowError("already exists");
    });
 
    test("user exists, login succeeds", () => {
      const app = new SpecialApp();
      const result = app.loginUser("a", "abc");    
      expect(result).toBe(true);                   
    });
  });
});

要求用户缓存为空

Requires the user cache to be empty

将用户添加到缓存中

Adds a user to the cache

要求缓存包含用户

Requires the cache to contain the user

请注意,第一个和第三个测试都依赖于第二个测试。第一个测试要求第二个测试尚未执行,因为它需要用户缓存为空。另一方面,第三个测试依赖于第二个测试来用预期用户填充缓存。如果我们仅使用 Jest 的关键字运行第三个测试test.only,则测试将失败:

Notice that the first and third tests both rely on the second test. The first test requires that the second test has not executed yet, because it needs the user cache to be empty. On the other hand, the third test relies on the second test to fill up the cache with the expected user. If we run only the third test using Jest’s test.only keyword, the test would fail:

test.only ("用户存在,登录成功", () => {
   const app = new SpecialApp();
   const 结果 = app.loginUser("a", "abc");
   期望(结果)。toBe(真);
 });
test.only("user exists, login succeeds", () => {
   const app = new SpecialApp();
   const result = app.loginUser("a", "abc");
   expect(result).toBe(true); 
 });

当我们尝试重用部分测试而不提取辅助函数时,通常会发生这种反模式。我们最终期望首先运行不同的测试,从而使我们免于进行一些设置。这会起作用,直到不起作用为止。

This antipattern usually happens when we try to reuse parts of tests without extracting helper functions. We end up expecting a different test to run first, saving us from doing some of the setup. This works, until it doesn’t.

我们可以通过几个步骤来重构它:

We can refactor this in a few steps:

  • 提取用于添加用户的辅助函数。

  • Extract a helper function for adding a user.

  • 重复使用此函数进行多次测试。

  • Reuse this function for multiple tests.

  • 在测试之间重置用户缓存。

  • Reset the user cache between tests.

以下清单显示了我们如何重构测试以避免此问题。

The following listing shows how we could refactor the tests to avoid this problem.

清单 8.9 重构测试以消除顺序依赖性

Listing 8.9 Refactoring tests to remove order dependence

const addDefaultUser = () =>                               
  getUserCache().addUser({
    键:“a”,
    密码:“abc”,
  });
const makeSpecialApp = () => new SpecialApp();            
描述(“测试依赖 v2”,()=> {
  beforeEach(() => getUserCache().reset());               
  描述("用户缓存", () => {                           
    test("只能添加缓存使用一次", () => {
      添加默认用户();                                   
 
      期望(()=> addDefaultUser())
        .toThrowError("已经存在");
    });
  });
 
  描述(“登录用户与登录用户”,()=>{          
    test("用户存在,登录成功", () => {
      添加默认用户();                                   
      const app = makeSpecialApp();
 
      const 结果 = app.loginUser("a", "abc");
      期望(结果)。toBe(真);
    });
 
    test("用户缺失,登录失败", () => {
      const app = makeSpecialApp();
 
      const 结果 = app.loginUser("a", "abc");
      期望(结果).toBe(假);
    });
  });
});
const addDefaultUser = () =>                              
  getUserCache().addUser({
    key: "a",
    password: "abc",
  });
const makeSpecialApp = () => new SpecialApp();            
describe("Test Dependence v2", () => {
  beforeEach(() => getUserCache().reset());               
  describe("user cache", () => {                          
    test("can only add cache use once", () => {
      addDefaultUser();                                   
 
      expect(() => addDefaultUser())
        .toThrowError("already exists");
    });
  });
 
  describe("loginUser with loggedInUser", () => {         
    test("user exists, login succeeds", () => {
      addDefaultUser();                                   
      const app = makeSpecialApp();
 
      const result = app.loginUser("a", "abc");
      expect(result).toBe(true);
    });
 
    test("user missing, login fails", () => {
      const app = makeSpecialApp();
 
      const result = app.loginUser("a", "abc");
      expect(result).toBe(false);
    });
  });
});

提取的用户创建辅助函数

Extracted user-creation helper function

提取工厂函数

Extracted factory function

在测试之间重置用户缓存

Resets user cache between tests

新的嵌套描述函数

New nested describe functions

调用可重用的辅助函数

Calls reusable helper functions

这里发生了几件事。首先,我们提取了两个辅助函数:一个makeSpecialApp工厂函数和一个addDefaultUser可以重用的辅助函数。接下来,我们创建了一个非常重要的beforeEach函数,用于在每次测试之前重置用户缓存。每当我有这样的共享资源时,我几乎总是有一个beforeEachorafterEach函数在测试运行之前或之后将其重置为原始状态。

There are several things going on here. First, we extracted two helper functions: a makeSpecialApp factory function and an addDefaultUser helper function that we can reuse. Next, we created a very important beforeEach function that resets the user cache before each test. Whenever I have a shared resource like that, I almost always have a beforeEach or afterEach function that resets it to its original condition before or after the test runs.

第一个和第三个测试现在在它们自己的小嵌套describe结构中运行。它们还都使用makeSpecialApp工厂函数,其中一个用于addDefaultUser确保它不需要先运行任何其他测试。第二个测试也在它自己的嵌套describe函数中运行并重用该addDefaultUser函数。

The first and the third tests now run in their own little nested describe structure. They also both use the makeSpecialApp factory function, and one of them is using addDefaultUser to make sure it does not require any other test to run first. The second test also runs in its own nested describe function and reuses the addDefaultUser function.

8.2 重构以提高可维护性

8.2 Refactoring to increase maintainability

到目前为止,我已经讨论了迫使我们做出改变的测试失败。现在让我们讨论我们选择进行的更改,以使测试随着时间的推移更容易维护。

Up until now, I’ve discussed test failures that force us to make changes. Let’s now discuss changes that we choose to make, to make tests easier to maintain over time.

8.2.1 避免测试私有或受保护的方法

8.2.1 Avoid testing private or protected methods

本节更适用于面向对象语言以及 TypeScript。私有或受保护的方法通常是私有的,这在开发人员看来是有充分理由的。有时它是为了隐藏实现细节,以便以后可以更改实现而不改变可观察的行为。也可能是出于安全相关或 IP 相关的原因(例如混淆)。

This section applies more to object-oriented languages as well as TypeScript. Private or protected methods are usually private for a good reason in the developer’s mind. Sometimes it’s to hide implementation details, so that the implementation can change later without changing the observable behavior. It could also be for security-related or IP-related reasons (obfuscation, for example).

当您测试私有方法时,您正在针对系统内部的合同进行测试。内部契约是动态的,当您重构系统时它们可能会发生变化。当它们发生变化时,即使系统的整体功能保持不变,您的测试也可能会失败,因为某些内部工作的完成方式不同。出于测试目的,您需要关心的只是公共契约(可观察的行为)。即使可观察到的行为是正确的,测试私有方法的功能也可能会导致测试失败。

When you test a private method, you’re testing against a contract internal to the system. Internal contracts are dynamic, and they can change when you refactor the system. When they change, your test could fail because some internal work is being done differently, even though the overall functionality of the system remains the same. For testing purposes, the public contract (the observable behavior) is all you need to care about. Testing the functionality of private methods may lead to breaking tests, even though the observable behavior is correct.

可以这样想:私有方法不存在于真空中。在接下来的某个地方,必须有东西调用它,否则它永远不会被触发。通常有一个公共方法最终会调用这个私有方法,如果没有,则在调用链上总会有一个公共方法被调用。这意味着任何私有方法始终是系统中更大的工作单元或用例的一部分,该单元以公共 API 开始,以三个最终结果之一结束:返回值、状态更改或第三方调用(或全部三个)。

Think of it this way: no private method exists in a vacuum. Somewhere down the line, something has to call it, or it will never get triggered. Usually there’s a public method that ends up invoking this private one, and if not, there’s always a public method up the chain of calls that gets invoked. This means that any private method is always part of a bigger unit of work, or use case in the system, that starts out with a public API and ends with one of the three end results: return value, state change, or third-party call (or all three).

因此,如果您看到私有方法,请在系统中找到将使用该方法的公共用例。如果您仅测试私有方法并且它有效,这并不意味着系统的其余部分正确使用此私有方法或正确处理它提供的结果。您可能拥有一个内部运行良好的系统,但公共 API 中所有优秀的内部内容都被错误地使用。

So if you see a private method, find the public use case in the system that will exercise it. If you test only the private method and it works, that doesn’t mean that the rest of the system is using this private method correctly or handles the results it provides correctly. You might have a system that works perfectly on the inside, but all that nice inside stuff is used incorrectly from the public APIs.

有时,如果私有方法值得测试,那么可能值得将其公开、静态或至少内部,并针对使用它的任何代码定义公共契约。在某些情况下,如果将方法完全放在不同的类中,设计可能会更清晰。我们稍后将讨论这些方法。

Sometimes, if a private method is worth testing, it might be worth making it public, static, or at least internal, and defining a public contract against any code that uses it. In some cases, the design may be cleaner if you put the method in a different class altogether. We’ll look at those approaches in a moment.

这是否意味着代码库中最终不应该有私有方法?不会。在测试驱动设计中,您通常会针对公共方法编写测试,然后这些公共方法会被重构为调用更小的私有方法。与此同时,针对公共方法的测试不断通过。

Does this mean there should eventually be no private methods in the codebase? No. With test-driven design, you usually write tests against methods that are public, and those public methods are later refactored into calling smaller, private methods. All the while, the tests against the public methods continue to pass.

公开方法

Making methods public

公开方法不一定是坏事。在一个更加实用的世界中,这甚至不是问题。这种做法似乎违背了我们许多人从小就遵循的面向对象原则,但情况并非总是如此。

Making a method public isn’t necessarily a bad thing. In a more functional world, it’s not even an issue. This practice may seem to go against the object-oriented principles many of us were raised on, but that’s not always the case.

考虑一下,想要测试一个方法可能意味着该方法具有已知的行为或与调用代码的契约。通过将其公开,您就将其正式化。通过将方法保持为私有,您可以告诉所有追随您的开发人员,他们可以更改该方法的实现,而不必担心使用该方法的未知代码。

Consider that wanting to test a method could mean that the method has a known behavior or contract against the calling code. By making it public, you’re making this official. By keeping the method private, you tell all the developers who come after you that they can change the implementation of the method without worrying about unknown code that uses it.

将方法提取到新类或模块

Extracting methods to new classes or modules

如果您的方法包含大量可以独立存在的逻辑,或者它在类或模块中使用仅与相关方法相关的专用状态变量,则最好将该方法提取到新类中或者在系统中具有特定角色的自己的模块。然后您可以单独测试该类。Michael Feathers 的《有效处理旧代码》(Pearson,2004 年)提供了有关此技术的一些很好的示例,而Robert Martin 的《Clean Code》(Pearson,2008 年)可以帮助您了解何时这是一个好主意。

If your method contains a lot of logic that can stand on its own, or it uses specialized state variables in the class or module that are relevant only to the method in question, it may be a good idea to extract the method into a new class or its own module with a specific role in the system. You can then test that class separately. Michael Feathers’ Working Effectively with Legacy Code (Pearson, 2004) has some good examples of this technique, and Clean Code by Robert Martin (Pearson, 2008) can help you figure out when this is a good idea.

使无状态私有方法成为公共和静态

Making stateless private methods public and static

如果您的方法是完全无状态的,有些人会选择通过使其静态(在支持此功能的语言中)来重构该方法。这使得它更易于测试,但也表明该方法是一种实用方法,具有由其名称指定的已知公共契约。

If your method is completely stateless, some people choose to refactor the method by making it static (in languages that support this feature). That makes it much more testable but also states that the method is a sort of utility method that has a known public contract specified by its name.

8.2.2 保持测试干燥

8.2.2 Keep tests DRY

作为开发人员,单元测试中的重复对您的伤害与生产代码中的重复一样严重,甚至更严重。这是因为对具有重复项的代码进行任何更改都会迫使您也更改所有重复项。当您处理测试时,开发人员只是避免这种麻烦并删除或忽略测试而不是修复它们的风险更大。

Duplication in your unit tests can hurt you, as a developer, just as much as, if not more than, duplication in production code. That’s because any change in a piece of code that has duplicates will force you to change all the duplicates as well. When you’re dealing with tests, there’s more risk of the developer just avoiding this trouble and deleting or ignoring tests instead of fixing them.

DRY(不要重复自己)原则应该在测试代码中有效,就像在生产代码中一样。重复的代码意味着当您测试某一方面的更改时,需要更改更多代码。更改构造函数或更改使用类的语义可能会对具有大量重复代码的测试产生重大影响。

The DRY (don’t repeat yourself) principle should be in effect in test code just as in production code. Duplicated code means there’s more code to change when one aspect you test against changes. Changing a constructor or changing the semantics of using a class can have a major effect on tests that have a lot of duplicated code.

正如我们在本章前面的示例中所看到的,使用辅助函数可以帮助减少测试中的重复。

As we’ve seen in previous examples in this chapter, using helper functions can help to reduce duplication in tests.

警告删除重复项也可能走得太远并损害可读性。我们将在下一章中讨论可读性。

warning Removing duplication can also go too far and hurt readability. We’ll talk about that in the next chapter, on readability.

8.2.3 避免设置方法

8.2.3 Avoid setup methods

我不喜欢在每次测试之前发生一次并且通常用于删除重复的beforeEach函数(也称为设置函数)。我更喜欢使用辅助函数。设置功能太容易被滥用。开发人员倾向于将它们用于不该做的事情,结果导致测试的可读性和可维护性降低。

I’m not a fan of the beforeEach function (also called a setup function) that happens once before each test and is often used to remove duplication. I much prefer using helper functions. Setup functions are too easy to abuse. Developers tend to use them for things they weren’t meant for, and tests become less readable and less maintainable as a result.

许多开发人员以多种方式滥用设置方法:

Many developers abuse setup methods in several ways:

  • 在设置方法中初始化仅在文件中的某些测试中使用的对象

  • Initializing objects in the setup method that are used in only some tests in the file

  • 设置代码冗长且难以理解

  • Having setup code that’s lengthy and hard to understand

  • 在设置方法中设置模拟和伪造对象

  • Setting up mocks and fake objects within the setup method

此外,设置方法也有限制,您可以通过使用简单的辅助方法来解决这些限制:

Also, setup methods have limitations, which you can get around by using simple helper methods:

  • 设置方法仅在您需要初始化时才有帮助。

  • Setup methods can only help when you need to initialize things.

  • 设置方法并不总是删除重复项的最佳选择。删除重复并不总是与创建和初始化对象的新实例有关。有时它是关于删除断言逻辑中的重复或以特定方式调用代码。

  • Setup methods aren’t always the best candidates for duplication removal. Removing duplication isn’t always about creating and initializing new instances of objects. Sometimes it’s about removing duplication in assertion logic or calling out code in a specific way.

  • 设置方法不能有参数或返回值。

  • Setup methods can’t have parameters or return values.

  • 设置方法不能用作返回值的工厂方法。它们在测试执行之前运行,因此它们的工作方式必须更加通用。测试有时需要请求特定的事物或使用特定测试的参数调用共享代码(例如,检索对象并将其属性设置为特定值)。

  • Setup methods can’t be used as factory methods that return values. They’re run before the test executes, so they must be more generic in the way they work. Tests sometimes need to request specific things or call shared code with a parameter for the specific test (for example, retrieving an object and setting its property to a specific value).

  • 设置方法应该只包含适用于当前测试类中所有测试的代码,否则该方法将更难以阅读和理解。

  • Setup methods should only contain code that applies to all the tests in the current test class, or the method will be harder to read and understand.

我几乎完全停止使用我编写的测试的设置方法。测试代码应该是漂亮和干净的,就像生产代码一样,但是如果您的生产代码看起来很糟糕,请不要用它作为拐杖来编写不可读的测试。使用工厂和辅助方法,让世界变得更美好,让一代开发人员在 5 到 10 年内必须维护您的代码。

I’ve almost entirely stopped using setup methods for the tests I write. Test code should be nice and clean, just like production code, but if your production code looks horrible, please don’t use that as a crutch to write unreadable tests. Use factory and helper methods, and make the world a better place for the generation of developers that will have to maintain your code in 5 or 10 years.

注意:beforeEach我们在第 8.2.3 节(清单 8.9)和第 2 章中查看了从使用函数转向辅助函数的示例。

Note We looked at an example of moving from using beforeEach to helper functions in section 8.2.3 (listing 8.9) and also in chapter 2.

8.2.4 使用参数化测试去除重复

8.2.4 Use parameterized tests to remove duplication

如果所有测试看起来都相同,则替换设置方法的另一个好选择是使用参数化测试。不同语言的不同测试框架支持参数化测试 - 如果您使用 Jest,则可以使用内置test.eachit.each函数。

Another great option for replacing setup methods, if all your tests look the same, is to use parameterized tests. Different test frameworks in different languages support parameterized tests—if you’re using Jest, you can use the built-in test.each or it.each functions.

beforeEach参数化有助于将设置逻辑移动到测试的排列部分,否则这些设置逻辑将保持重复或驻留在块中。它还有助于避免重复断言逻辑,如以下清单所示。

Parameterization helps move the setup logic that would otherwise remain duplicated or would reside in the beforeEach block to the test’s arrange section. It also helps avoid duplication of the assertion logic, as shown in the following listing.

清单 8.10 使用 Jest 进行参数化测试

Listing 8.10 Parameterized tests with Jest

const sum = 数字 => {
    if (numbers.length > 0) {
        返回parseInt(数字);
    }
    返回0;
};
 
描述('与常规测试相加',()=> {
    test('总和1', () => {
        const 结果 = sum('1');                  
        期望(结果).toBe(1);                   
    });
    test('总和2', () => {
        const 结果 = sum('2');                  
        期望(结果).toBe(2);                   
    });
});
描述('与参数化测试相加',()=> {
    test.each([ 
        ['1', 1],                                  
        ['2', 2]                                   
    ]) ('add ,for %s , 返回该数字', (输入, 预期) => {
            const 结果 = sum(输入);            ❸expect 
            (结果).toBe(预期);        
        }
    )
});
const sum = numbers => {
    if (numbers.length > 0) {
        return parseInt(numbers);
    }
    return 0;
};
 
describe('sum with regular tests', () => {
    test('sum number 1', () => {
        const result = sum('1');                  
        expect(result).toBe(1);                   
    });
    test('sum number 2', () => {
        const result = sum('2');                  
        expect(result).toBe(2);                   
    });
});
describe('sum with parameterized tests', () => {
    test.each([
        ['1', 1],                                 
        ['2', 2]                                  
    ])('add ,for %s, returns that number', (input, expected) => {
            const result = sum(input);            
            expect(result).toBe(expected);        
        }
    )
});

重复的设置和断言逻辑

Duplicated setup and assertion logic

用于设置和断言的测试数据

Test data used for setup and assertion

无需重复的设置和断言

Setup and assertion without duplication

在第一个describe块中,我们有两个测试,它们使用不同的输入值和预期输出相互重复。在第二个describe块中,我们使用test.each提供一个数组数组,其中每个子数组列出了测试函数所需的所有值。

In the first describe block, we have two tests that repeat each other with different input values and expected outputs. In the second describe block, we’re using test.each to provide an array of arrays, where each subarray lists all the values needed for the test function.

参数化测试可以帮助减少测试之间的大量重复,但我们应该小心,仅在重复完全相同的场景并且仅更改输入和输出的情况下才使用此技术。

Parameterized tests can help reduce a lot of duplication between tests, but we should be careful to only use this technique in cases where we repeat the exact same scenario and only change the input and output.

8.3 避免过度规范

8.3 Avoid overspecification

过度指定的测试包含有关被测特定单元(生产代码)应如何实现其内部行为的假设,而不是仅检查可观察的行为(退出点)是否正确。

An overspecified test is one that contains assumptions about how a specific unit under test (production code) should implement its internal behavior, instead of only checking that the observable behavior (exit points) is correct.

以下是单元测试经常被过度指定的方式:

Here are ways unit tests are often overspecified:

  • 测试断言被测对象的纯粹内部状态。

  • A test asserts purely internal state in an object under test.

  • 一个测试使用多个模拟。

  • A test uses multiple mocks.

  • 测试使用桩作为模拟。

  • A test uses stubs as mocks.

  • 当不需要时,测试假设特定顺序或精确字符串匹配。

  • A test assumes a specific order or exact string matches when that isn’t required.

让我们看一些过度指定测试的示例。

Let’s look at some examples of overspecified tests.

8.3.1 模拟的内部行为过度规范

8.3.1 Internal behavior overspecification with mocks

一个非常常见的反模式是验证类或模块中的内部函数是否被调用,而不是检查工作单元的退出点。这是一个调用内部函数的密码验证器,测试不应该关心该函数。

A very common antipattern is to verify that an internal function in a class or module is called, instead of checking the exit point of the unit of work. Here’s a password verifier that calls an internal function, which the test shouldn’t care about.

清单 8.11 调用受保护函数的生产代码

Listing 8.11 Production code that calls a protected function

导出类PasswordVerifier4 {
  私人_规则:((输入:字符串)=>布尔)[];
  私人_logger:IComplicatedLogger;
 
  构造函数(规则:((输入)=>布尔值)[],
      记录器:IComplicatedLogger) {
    this._rules = 规则;
    this._logger = 记录器;
  }
 
  验证(输入:字符串):布尔值{
    const failed = this.findFailedRules(input);   
 
    if (失败.length === 0) {
      this._logger.info("通过");
      返回真;
    }
    this._logger.info("失败");
    返回假;
  }
 
 受保护的 findFailedRules(输入:字符串) {       
    const 失败 = this._rules
      .map((规则) => 规则(输入))
      .filter((结果) => 结果 === false);
    返回失败;
 } 
}
export class PasswordVerifier4 {
  private _rules: ((input: string) => boolean)[];
  private _logger: IComplicatedLogger;
 
  constructor(rules: ((input) => boolean)[],
      logger: IComplicatedLogger) {
    this._rules = rules;
    this._logger = logger;
  }
 
  verify(input: string): boolean {
    const failed = this.findFailedRules(input);   
 
    if (failed.length === 0) {
      this._logger.info("PASSED");
      return true;
    }
    this._logger.info("FAIL");
    return false;
  }
 
  protected findFailedRules(input: string) {      
    const failed = this._rules
      .map((rule) => rule(input))
      .filter((result) => result === false);
    return failed;
  }
}

调用内部函数

Call to the internal function

内部功能

Internal function

请注意,我们调用受保护的findFailedRules函数来获取结果,然后对结果进行计算。

Notice that we’re calling the protected findFailedRules function to get a result from it, and then doing a calculation on the result.

这是我们的测试。

Here’s our test.

清单 8.12 验证对受保护函数的调用的过度指定测试

Listing 8.12 An overspecified test verifying a call to a protected function

描述(“验证者4”,()=> {
  描述(“过度指定受保护的函数调用”,()=> {
    test("checkfailedFules 被调用", () => {
      const pv4 = 新密码验证器4(
        [], Substitute.for<IComplicatedLogger>()
      );
      const failedMock = jest.fn(() => []);     
      pv4["findFailedRules"] = failedMock;      
 
pv4.verify("abc");
 
     期望(failedMock).toHaveBeenCalled();    
    });
  });
});
describe("verifier 4", () => {
  describe("overspecify protected function call", () => {
    test("checkfailedFules is called", () => {
      const pv4 = new PasswordVerifier4(
        [], Substitute.for<IComplicatedLogger>()
      ); 
      const failedMock = jest.fn(() => []);     
      pv4["findFailedRules"] = failedMock;      
 
pv4.verify("abc");
 
      expect(failedMock).toHaveBeenCalled();    
    });
  });
});

模拟内部函数

Mocking the internal function

验证内部函数调用。不要这样做。

Verifying the internal function call. Don’t do this.

这里的反模式是我们正在证明一些不是出口点的东西。我们正在检查代码是否调用了某些内部函数,但这真正证明了什么?我们并不检查计算结果是否正确;而是检查结果是否正确。我们只是为了测试而测试。

The antipattern here is that we’re proving something that isn’t an exit point. We’re checking that the code calls some internal function, but what does that really prove? We’re not checking that the calculation was correct on the result; we’re simply testing for the sake of testing.

如果函数返回一个值,通常强烈表明我们不应该模拟该函数,因为函数调用本身并不代表退出点。退出点是函数返回的值verify()。我们不应该关心内部函数是否存在。

If the function is returning a value, usually that’s a strong indication that we shouldn’t mock that function because the function call itself does not represent the exit point. The exit point is the value returned from the verify() function. We shouldn’t care whether the internal function even exists.

通过验证不是出口点的受保护函数的模拟,我们将测试实现耦合到被测代码的内部实现,但没有真正的好处。当内部调用发生变化时(它们将会发生变化),我们还必须更改与这些调用相关的所有测试,这不会是一个积极的体验。您可以在 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning,2020 年)的第 5 章中阅读有关模拟及其与测试脆弱性的关系的更多信息。

By verifying against a mock of a protected function that is not an exit point, we are coupling our test implementation to the internal implementation of the code under test, for no real benefit. When the internal calls change (and they will) we will also have to change all the tests associated with these calls, and that will not be a positive experience. You can read more about mocks and their relation to test fragility in chapter 5 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020).

我们应该做什么呢?

What should we do instead?

寻找出口点。真正的退出点取决于我们希望执行的测试类型:

Look for the exit point. The real exit point depends on the type of test we wish to perform:

  • 基于值的测试——对于基于值的测试(我强烈建议您尽可能倾向于这种测试),我们从被调用的函数中查找返回值。在本例中,该verify函数返回一个值,因此它是基于值的测试的完美候选者:pv4.verify("abc")

  • Value-based test—For a value-based test, which I would highly recommend you lean toward when possible, we look for a return value from the called function. In this case, the verify function returns a value, so it’s the perfect candidate for a value-based test: pv4.verify("abc").

  • 基于状态的测试- 对于基于状态的测试,我们查找同级函数(与入口点存在于同一范围级别的函数)或受调用该verify()函数影响的同级属性。例如,firstname()lastname()可以被视为兄弟函数。这就是我们应该断言的地方。verify()在此代码库中,同一级别可见的调用不会影响任何内容,因此它不是基于状态的测试的良好候选者。

  • State-based test—For a state-based test, we look for a sibling function (a function that exists at the same level of scope as the entry point) or a sibling property that is affected by calling the verify() function. For example, firstname() and lastname() could be considered sibling functions. That is where we should be asserting. In this codebase, nothing is affected by calling verify() that is visible at the same level, so it is not a good candidate for state-based testing.

  • 第三方测试——对于第三方测试,我们必须使用模拟,这需要我们找出代码中的即发即忘位置。该findFailedRules函数不是这样,因为它实际上是将信息传递回我们的verify()函数。在这种情况下,我们不需要接管真正的第三方依赖。

  • Third-party test—For a third-party test, we would have to use a mock, and that would require us to find out where the fire-and-forget location is inside the code. The findFailedRules function isn’t that, because it is actually delivering information back to our verify() function. In this case, there’s no real third-party dependency that we have to take over.

8.3.2 精确输出和订购超规格

8.3.2 Exact outputs and ordering overspecification

常见的反模式是测试过度指定返回值集合的顺序和结构。在断言中指定整个集合及其每个项目通常更容易,但是使用这种方法,当集合的任何小细节发生变化时,我们隐式地承担了修复测试的负担。我们不应该使用单个巨大的断言,而应该将验证的不同方面分成更小的、显式的断言。

A common antipattern is when a test overspecifies the order and the structure of a collection of returned values. It’s often easier to specify the whole collection, along with each of its items, in the assertion, but with this approach, we implicitly take on the burden of fixing the test when any little detail of the collection changes. Instead of using a single huge assertion, we should separate different aspects of the verification into smaller, explicit asserts.

以下清单显示了一个verify()接受多个输入并返回结果对象列表的函数。

The following listing shows a verify() function that takes on multiple inputs and returns a list of result objects.

清单 8.13 返回输出列表的验证器

Listing 8.13 A verifier that returns a list of outputs

接口 IResult {
  结果:布尔值;
  输入:字符串;
}
 
导出类PasswordVerifier5 {
  私人_规则:((输入:字符串)=>布尔)[];
 
  构造函数(规则:((输入)=>布尔值)[]){
    this._rules = 规则;
  }
 
 verify(inputs: string[]): IResult[] { 
    const failedResults = 
      input.map((input) => this.checkSingleInput(input)); 
    返回失败结果;
 }
 
  私人 checkSingleInput(输入:字符串):IResult {
    const failed = this.findFailedRules(input);
    返回 {
      输入,
      结果:失败。长度=== 0,
    };
  }
interface IResult {
  result: boolean;
  input: string;
}
 
export class PasswordVerifier5 {
  private _rules: ((input: string) => boolean)[];
 
  constructor(rules: ((input) => boolean)[]) {
    this._rules = rules;
  }
 
  verify(inputs: string[]): IResult[] {
    const failedResults = 
      inputs.map((input) => this.checkSingleInput(input));
    return failedResults;
  }
 
  private checkSingleInput(input: string): IResult {
    const failed = this.findFailedRules(input);
    return {
      input,
      result: failed.length === 0,
    };
  }

该函数返回一个对象verify()数组,每个对象中都有一个and 。以下清单显示了一个测试,该测试对结果的排序和每个结果的结构进行隐式检查,并检查结果的值。IResultinputresult

This verify() function returns an array of IResult objects with an input and result in each. The following listing shows a test that makes an implicit check on both the ordering of the results and the structure of each result, as well as checking the value of the results.

清单 8.14 过度指定结果的顺序和模式

Listing 8.14 Overspecifying order and schema of the result

test("过度指定顺序和模式", () => {
  常量 pv5 =
    新的PasswordVerifier5([input => input.includes("abc")]);
 
  const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  Expect(results).toEqual([             
    { 输入: "a", 结果: false },      
    { 输入: "ab", 结果: false },     
    { 输入: "abc", 结果: true },     
    { 输入: "abcd", 结果: true },   
  ]);
});
test("overspecify order and schema", () => {
  const pv5 = 
    new PasswordVerifier5([input => input.includes("abc")]);
 
  const results = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  expect(results).toEqual([           
    { input: "a", result: false },    
    { input: "ab", result: false },   
    { input: "abc", result: true },   
    { input: "abcd", result: true },  
  ]);
});

一个巨大的断言

A single huge assert

这项测试将来可能会发生什么变化?以下是其改变的几个原因:

How might this test change in the future? Here are quite a few reasons for it to change:

  • 当数组长度results改变时

  • When the length of the results array changes

  • 当每个result对象获得或删除一个属性时(即使测试不关心这些属性)

  • When each result object gains or removes a property (even if the test doesn’t care about those properties)

  • 当结果的顺序发生变化时(即使对于当前测试可能并不重要)

  • When the order of the results changes (even if it might not be important for the current test)

如果将来发生任何这些变化,但您的测试只是专注于检查验证器的逻辑及其输出的结构,那么维护此测试将会带来很多痛苦。

If any of these changes happens in the future, but your test is just focused on checking the logic of the verifier and the structure of its output, there’s going to be a lot of pain involved in maintaining this test.

我们可以通过仅验证对我们重要的部分来减轻一些痛苦。

We can reduce some of that pain by verifying only the parts that matter to us.

清单 8.15 忽略结果的模式

Listing 8.15 Ignoring the schema of the results

test("过度指定顺序但忽略模式", () => {
  常量 pv5 =
    新的PasswordVerifier5([(input) => input.includes("abc")]);
 
  const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  期望(结果.长度).toBe(4);
  期望(结果[0] 。结果)。toBe(假);
  期望(结果[1] 。结果)。toBe(假);
  期望(结果[2] .结果).toBe(true);
  期望(结果[3] .结果).toBe(true);
});
test("overspecify order but ignore schema", () => {
  const pv5 = 
    new PasswordVerifier5([(input) => input.includes("abc")]);
 
  const results = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  expect(results.length).toBe(4);
  expect(results[0].result).toBe(false);
  expect(results[1].result).toBe(false);
  expect(results[2].result).toBe(true);
  expect(results[3].result).toBe(true);
});

我们可以简单地断言输出中特定属性的值,而不是提供完整的预期输出。然而,如果结果的顺序发生变化,我们仍然会陷入困境。如果我们不关心顺序,我们可以简单地检查输出是否包含特定结果,如下所示。

Instead of providing the full expected output, we can simply assert on the values of specific properties in the output. However, we’re still stuck if the order of the results changes. If we don’t care about the order, we can simply check if the output contains a specific result, as follows.

清单 8.16 忽略顺序和模式

Listing 8.16 Ignoring order and schema

test("忽略顺序和模式", () => {
  常量 pv5 =
    新的PasswordVerifier5([(input) => input.includes("abc")]);
 
  const 结果 = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  期望(结果.长度).toBe(4);
  期望(findResultFor(“a”))。toBe(假);
  期望(findResultFor(“ab”))。toBe(假);
  期望( findResultFor("abc") ).toBe(true);
  期望( findResultFor("abcd") ).toBe(true);
});
test("ignore order and schema", () => {
  const pv5 = 
    new PasswordVerifier5([(input) => input.includes("abc")]);
 
  const results = pv5.verify(["a", "ab", "abc", "abcd"]);
 
  expect(results.length).toBe(4);
  expect(findResultFor("a")).toBe(false);
  expect(findResultFor("ab")).toBe(false);
  expect(findResultFor("abc")).toBe(true);
  expect(findResultFor("abcd")).toBe(true);
});

在这里,我们用于findResultFor()查找给定输入的特定结果。现在结果的顺序可以改变,或者可以添加额外的值,但是只有当正确或错误结果的计算发生改变时,我们的测试才会失败。

Here we are using findResultFor() to find the specific result for a given input. Now the order of the results can change, or extra values can be added, but our test will only fail if the calculation of the true or false results changes.

人们倾向于重复的另一个常见反模式是,当仅需要字符串的特定部分时,对单元的返回值或属性中的硬编码字符串进行断言。问问自己,“我可以检查一个字符串是否包含某些内容而不是等于某些内容吗?” 这是一个密码验证器,它向我们提供一条消息,描述验证过程中违反了多少规则。

Another common antipattern people tend to repeat is to assert against hardcoded strings in the unit’s return value or properties, when only a specific part of a string is necessary. Ask yourself, “Can I check if a string contains something rather than equals something?” Here’s a password verifier that gives us a message describing how many rules were broken during a verification.

清单 8.17 返回字符串消息的验证器

Listing 8.17 A verifier that returns a string message

导出类PasswordVerifier6 {
  私人_规则:((输入:字符串)=>布尔)[];
  私人_味精:字符串=“”;
 
  构造函数(规则:((输入)=>布尔值)[]){
    this._rules = 规则;
  }
 
 getMsg(): string {
    返回这个。_味精;
 }
 
  验证(输入:字符串[]):IResult[] {
    常量所有结果=
      input.map((input) => this.checkSingleInput(input));
    this.setDescription(allResults);
    返回所有结果;
  }
 
  私有 setDescription(结果: IResult[]) {
    const failed = results.filter((res) => !res.result);
    这。_ msg = `您有 ${failed.length} 条失败的规则。`; 
  }
export class PasswordVerifier6 {
  private _rules: ((input: string) => boolean)[];
  private _msg: string = "";
 
  constructor(rules: ((input) => boolean)[]) {
    this._rules = rules;
  }
 
  getMsg(): string {
    return this._msg;
  }
 
  verify(inputs: string[]): IResult[] {
    const allResults = 
      inputs.map((input) => this.checkSingleInput(input));
    this.setDescription(allResults);
    return allResults;
  }
 
  private setDescription(results: IResult[]) {
    const failed = results.filter((res) => !res.result);
    this._msg = `you have ${failed.length} failed rules.`;
  }

以下清单显示了两个使用的测试getMsg()

The following listing shows two tests that use getMsg().

清单 8.18 使用相等性过度指定字符串

Listing 8.18 Overspecifying a string using equality

描述(“验证者6”,()=> {
  test("超过指定字符串", () => {
    常量 pv5 =
      新的PasswordVerifier6([(输入) => input.includes("abc")]);
 
    pv5.verify(["a", "ab", "abc", "abcd"]);
 
    const msg = pv5.getMsg();
    Expect(msg).toBe("你有 2 个失败的规则。");   
  });
 
  //这是编写此测试的更好方法
  test("更多面向未来的字符串检查", () => {
    常量 pv5 =
      新的PasswordVerifier6([(输入) => input.includes("abc")]);
 
    pv5.verify(["a", "ab", "abc", "abcd"]);
 
    const msg = pv5.getMsg();
    Expect(msg).toMatch(/2 失败/);                
  });
});
describe("verifier 6", () => {
  test("over specify string", () => {
    const pv5 = 
      new PasswordVerifier6([(input) => input.includes("abc")]);
 
    pv5.verify(["a", "ab", "abc", "abcd"]);
 
    const msg = pv5.getMsg();
    expect(msg).toBe("you have 2 failed rules.");   
  });
 
  //Here's a better way to write this test
  test("more future proof string checking", () => {
    const pv5 = 
      new PasswordVerifier6([(input) => input.includes("abc")]);
 
    pv5.verify(["a", "ab", "abc", "abcd"]);
 
    const msg = pv5.getMsg();
    expect(msg).toMatch(/2 failed/);                
  });
});

过于具体的字符串期望

Overly specific string expectation

对字符串进行断言的更好方法

A better way to assert against a string

第一个测试检查该字符串是否完全等于另一个字符串。这常常适得其反,因为字符串是用户界面的一种形式。随着时间的推移,我们倾向于稍微改变它们并修饰它们。例如,我们关心字符串末尾有一个句点吗?我们的测试需要我们关心,但断言的实质是显示正确的数字(特别是因为字符串在不同的计算机语言或文化中发生变化,但数字通常保持不变)。

The first test checks that the string exactly equals another string. This backfires often, because strings are a form of user interface. We tend to change them slightly and embellish them over time. For example, do we care that there is a period at the end of the string? Our test requires us to care, but the meat of the assert is the correct number being shown (especially since strings change in different computer languages or cultures, but numbers usually stay the same).

第二个测试只是在消息中查找“2 failed”字符串。这使得测试更加面向未来:字符串可能会略有变化,但核心消息仍然存在,而不会迫使我们更改测试。

The second test simply looks for the “2 failed” string inside the message. This makes the test more future-proof: the string might change slightly, but the core message remains without forcing us to change the test.

概括

Summary

  • 测试随着被测系统的发展和变化而变化。如果我们不注意可维护性,我们的测试可能需要我们进行太​​多更改,以至于可能不值得更改它们。相反,我们最终可能会删除它们,并放弃创建它们的所有辛苦工作。为了使测试从长远来看有用,它们应该只因我们真正关心的原因而失败。

  • Tests grow and change with the system under test. If we don’t pay attention to maintainability, our tests may require so many changes from us that it might not be worth changing them. We may instead end up deleting them, and throwing away all the hard work that went into creating them. For tests to be useful in the long run, they should fail only for reasons we truly care about.

  • 真正的失败是测试因在生产代码中发现错误而失败。错误失败是指测试由于任何其他原因而失败。

  • A true failure is when a test fails because it finds a bug in production code. A false failure is when a test fails for any other reason.

  • 为了估计测试的可维护性,我们可以测量一段时间内错误测试失败的数量以及每次失败的原因。

  • To estimate test maintainability, we can measure the number of false test failures and the reason for each failure, over time.

  • 测试可能会因多种原因而错误地失败:它与另一个测试冲突(在这种情况下,您应该将其删除);生产代码 API 的更改(可以通过使用工厂和辅助方法来缓解);其他测试中的更改(此类测试应相互解耦)。

  • A test may falsely fail for multiple reasons: it conflicts with another test (in which case, you should just remove it); changes in the production code’s API (this can be mitigated by using factory and helper methods); changes in other tests (such tests should be decoupled from each other).

  • 避免测试私有方法。私有方法是实现细节,并且生成的测试将是脆弱的。测试应该验证可观察的行为——与最终用户相关的行为。有时,需要测试私有方法是缺少抽象的标志,这意味着该方法应该公开,甚至提取到单独的类中。

  • Avoid testing private methods. Private methods are implementation details, and the resulting tests are going to be fragile. Tests should verify observable behavior—behavior that is relevant for the end user. Sometimes, the need to test a private method is a sign of a missing abstraction, which means the method should be made public or even be extracted into a separate class.

  • 保持测试干燥。使用辅助方法抽象排列和断言部分的非必要细节。这将简化您的测试,而无需将它们相互耦合。

  • Keep tests DRY. Use helper methods to abstract nonessential details of arrange and assert sections. This will simplify your tests without coupling them to each other.

  • 避免使用诸如函数之类的设置方法beforeEach。再次使用辅助方法。另一种选择是参数化您的测试,从而将块的内容移动beforeEach到测试的排列部分。

  • Avoid setup methods such as the beforeEach function. Once again, use helper methods instead. Another option is to parameterize your tests and therefore move the content of the beforeEach block to the test’s arrange section.

  • 避免过度规范。过度指定的示例包括断言被测代码的私有状态、断言对桩的调用,或者在不需要时假设结果集合中元素的特定顺序或精确的字符串匹配。

  • Avoid overspecification. Examples of overspecification are asserting the private state of the code under test, asserting against calls on stubs, or assuming the specific order of elements in a result collection or exact string matches when that isn’t required.

第 4 部分 设计与流程

Part 4 Design and process

最后几章涵盖了向现有组织或代码库引入单元测试时将面临的问题以及所需的技术。

These final chapters cover the problems you’ll face and the techniques you’ll need when introducing unit testing to an existing organization or codebase.

在第 9 章中,我们将讨论测试可读性。我们将讨论测试的命名约定和它们的输入值。我们还将介绍测试构建和编写更好的断言消息的最佳实践。

In chapter 9, we’ll talk about test readability. We’ll discuss naming conventions for tests and input values for them. We’ll also cover best practices for test structuring and writing better assertion messages.

第 10 章解释了如何制定测试策略。我们将了解在测试新功能时您应该选择哪些测试级别,讨论测试级别中的常见反模式,并讨论测试配方策略。

Chapter 10 explains how to develop a testing strategy. We’ll look at which test levels you should prefer when testing a new feature, discuss common antipatterns in test levels, and talk about the test recipe strategy.

在第11章中,我们将处理在组织中实施单元测试的棘手问题,并且我们将介绍可以使您的工作变得更轻松的技术。本章提供了首次实施单元测试时常见的一些棘手问题的答案。

In chapter 11, we’ll deal with the tough issue of implementing unit testing in an organization, and we’ll cover techniques that can make your job easier. This chapter provides answers to some tough questions that are common when first implementing unit testing.

在第 12 章中,我们将研究与遗留代码相关的常见问题,并研究一些处理它的工具。

In chapter 12, we’ll look at common problems associated with legacy code and examine some tools for working with it.

9 可读性

9 Readability

本章涵盖

This chapter covers

  • 单元测试的命名约定
  • Naming conventions for unit tests
  • 编写可读的测试
  • Writing readable tests

如果没有可读性,您编写的测试对于以后阅读它们的人来说几乎毫无意义。可读性是测试编写者和几个月或几年后必须阅读测试的可怜人之间的连接线。测试是您在项目中向下一代程序员讲述的故事。它们使开发人员能够准确地了解应用程序的组成部分以及应用程序的启动位置。

Without readability, the tests you write are almost meaningless to whoever reads them later on. Readability is the connecting thread between the person who wrote the test and the poor soul who must read it a few months or years later. Tests are stories you tell the next generation of programmers on a project. They allow a developer to see exactly what an application is made of and where it started.

本章的重点是确保您之后的开发人员能够维护您编写的生产代码和测试。他们需要了解自己在做什么以及应该在哪里做。

This chapter is all about making sure the developers who come after you will be able to maintain the production code and the tests that you write. They’ll need to understand what they’re doing and where they should be doing it.

可读性有几个方面:

There are several facets to readability:

  • 命名单元测试

  • Naming unit tests

  • 命名变量

  • Naming variables

  • 将断言与操作分开

  • Separating asserts from actions

  • 设置和拆除

  • Setting up and tearing down

让我们一一回顾一下。

Let’s go through these one by one.

9.1 命名单元测试

9.1 Naming unit tests

命名标准很重要,因为它们为您提供了舒适的规则和模板,概述了您应该解释的测试内容。无论我如何订购它们,或者使用什么特定的框架或语言,我都会尝试确保这三个重要信息出现在测试名称或测试所在文件的结构中:

Naming standards are important because they give you comfortable rules and templates that outline what you should explain about the test. No matter how I order them, or what specific framework or language I am using, I try to make sure these three important pieces of information are present in the name of the test or in the structure of the file in which the test exists:

  • 工作单元的入口点(或正在测试的功能的名称)

  • The entry point to the unit of work (or the name of the feature being tested)

  • 您测试入口点的场景

  • The scenario under which you’re testing the entry point

  • 工作单元出口点的预期行为

  • The expected behavior of the exit point of the unit of work

入口点(或工作单元)的名称至关重要,以便您可以轻松了解正在测试的逻辑的起始范围。将此作为测试名称的第一部分还可以在测试文件中轻松导航和键入完成(如果您的 IDE 支持)。

The name of the entry point (or unit of work) is essential, so that you can easily understand the starting scope of the logic being tested. Having this as the first part of the test name also allows for easy navigation and as-you-type completion (if your IDE supports it) in the test file.

测试它的场景为您提供了名称的“with”部分:“当我使用空值调用入口点 X 时它应该执行 Y。”

The scenario under which it’s being tested gives you the “with” part of the name: “When I call entry point X with a null value, then it should do Y.”

工作单元出口点的预期行为是测试根据当前场景,用简单的英语指定工作单元应该做什么或返回什么,或者它应该如何表现:“当我使用空值,那么它应该执行从工作单元的出口点可见的 Y 操作。”

The expected behavior from the exit point of the unit of work is where the test specifies in plain English what the unit of work should do or return, or how it should behave, based on the current scenario: “When I call entry point X with a null value, then it should do Y as visible from this exit point of the unit of work.”

这三个要素必须存在于阅读测试的人眼睛附近的某个地方。有时它们可​​以全部封装在测试的函数名称中,有时您可以将它们包含在嵌套describe结构中。有时您可以简单地使用字符串描述作为测试的参数或注释。

These three elements have to exist somewhere close to the eyes of the person reading the test. Sometimes they can all be encapsulated in the test’s function name, and sometimes you can include them with nested describe structures. Sometimes you can simply use a string description as a parameter or annotation for the test.

下面的清单中显示了一些示例,所有示例都具有相同的信息,但布局不同。

Some examples are shown in the following listing, all with the same pieces of information, but laid out differently.

清单 9.1 相同的信息,不同的变体

Listing 9.1 Same information, different variations

test('verifyPassword,规则失败,根据rule.reason返回错误', () => { ... }
 
描述('验证密码',()=> {
  描述('有一个失败的规则', () => {
    it('根据规则返回错误。原因', () => { ... }
 
verifyPassword_withFailingRule_returnsErrorBasedonRuleReason()
test('verifyPassword, with a failing rule, returns error based on rule.reason', () => { ... }
 
describe('verifyPassword', () => {
  describe('with a failing rule', () => {
    it('returns error based on the rule.reason', () => { ... }
 
verifyPassword_withFailingRule_returnsErrorBasedonRuleReason()

当然,您可以想出其他方法来构建它。(谁说必须使用下划线?这只是我自己的偏好,旨在提醒我和其他人有三条信息。)。需要注意的关键点是,如果删除其中一条信息,就会迫使阅读测试的人阅读测试中的代码以找出答案,从而浪费宝贵的时间。

You can, of course, come up with other ways to structure this. (Who says you have to use underscores? That’s just my own preference for reminding me and others that there are three pieces of information.). The key point to take away is that if you remove one of these pieces of information, you’re forcing the person reading the test to read the code inside the test to find out the answer, wasting precious time.

以下列表显示了缺少信息的测试示例。

The following listing shows examples of tests with missing information.

清单 9.2 缺少信息的测试名称

Listing 9.2 Test names with missing information

test(失败的规则,根据rule.reason返回错误', () => { ... }       
 
test('verifyPassword, 根据rule.reason返回错误', () => { ... }    
 
test('verifyPassword, 规则失败', () => { ... }                   
test(failing rule, returns error based on rule.reason', () => { ... }      
 
test('verifyPassword, returns error based on rule.reason', () => { ... }   
 
test('verifyPassword, with a failing rule', () => { ... }                  

被测物是什么?

What is the thing under test?

这应该发生在什么时候?

When is this supposed to happen?

那么接下来会发生什么呢?

What’s supposed to happen then?

可读性的主要目标是将下一个开发人员从阅读测试代码的负担中解放出来,以便了解测试正在测试什么。

Your main goal with readability is to release the next developer from the burden of reading the test code in order to understand what the test is testing.

在测试名称中包含所有这些信息的另一个重要原因是,当自动构建管道失败时,该名称通常是唯一显示的内容。您将在失败的构建日志中看到失败测试的名称,但不会看到任何注释或测试代码。如果名称足够好,您可能不需要阅读测试代码或调试它们;只需阅读失败构建的日志,您就可以了解失败的原因。这可以节省宝贵的调试和阅读时间。

Another great reason to include all these pieces of information in the name of the test is that the name is usually the only thing that shows up when an automated build pipeline fails. You’ll see the names of the failed tests in the log of the build that failed, but you won’t see any comments or the code of the tests. If the names are good enough, you might not need to read the code of the tests or debug them; you may understand the cause of the failure simply by reading the log of the failed build. This can save precious debugging and reading time.

一个好的测试名称还有助于促进可执行文档的想法——如果您可以要求团队中的新开发人员阅读测试,以便他们能够了解特定组件或应用程序的工作原理,这是可读性的良好标志。如果他们无法仅通过测试来理解应用程序或组件的行为,那么这可能是可读性的危险信号。

A good test name also serves to contribute to the idea of executable documentation—if you can ask a developer who is new to the team to read the tests so they can understand how a specific component or application works, that’s a good sign of readability. If they can’t make sense of the application or the component’s behavior from the tests alone, it might be a red flag for readability.

9.2 魔法值和命名变量

9.2 Magic values and naming variables

您听说过“神奇价值观”这个词吗?听起来很棒,但事实恰恰相反。它实际上应该是“巫术价值观”来传达使用它们的负面影响。你问它们是什么?它们是硬编码的、未记录的或难以理解的常量或变量。提到魔法表明这些值有效,但你不知道为什么。

Have you heard the term “magic values”? It sounds awesome, but it’s the opposite of that. It should really be “witchcraft values” to convey the negative effects of using them. What are they, you ask? They are hardcoded, undocumented, or poorly understood constants or variables. The reference to magic indicates that these values work, but you have no idea why.

考虑以下测试。

Consider the following test.

清单 9.3 具有 magic 值的测试

Listing 9.3 A test with magic values

描述('密码验证器', () => {
  test('周末,抛出异常', () => {
))    
      .toThrowError("周末了!");
  });
});
describe('password verifier', () => {
  test('on weekends, throws exceptions', () => {
))   
      .toThrowError("It's the weekend!");
  });
});

魔法值

Magic values

该测试包含三个神奇值。一个没有写过测试、不知道被测试的API的人能轻易理解这个0值的含义吗?数组怎么样[]?该函数的第一个参数看起来像一个密码,但即使如此,它也具有神奇的品质。来!我们讨论一下:

This test contains three magic values. Can a person who didn’t write the test and doesn't know the API being tested easily understand what the 0 value means? How about the [] array? The first parameter to that function kind of looks like a password, but even that has a magical quality to it. Let’s discuss:

  • 0可能意味着很多事情。作为读者,我可能必须在代码中搜索,或者跳到被调用函数的签名中,才能理解这指定了星期几。

  • The 0 could mean so many things. As the reader, I might have to search around in the code, or jump into the signature of the called function, to understand that this specifies the day of the week.

  • []迫使我查看被调用函数的签名,以了解该函数需要密码验证规则数组,这意味着测试验证没有规则的情况。

  • The [] forces me to look at the signature of the called function to understand that the function expects a password verification rule array, which means the test verifies the case with no rules.

  • jhGGu78!似乎是一个明显的密码值,但作为读者我会遇到的一个大问题是,为什么这个特定值?这个特定密码有什么重要意义?对于这个测试来说,使用这个值而不是任何其他值显然很重要,因为它看起来非常具体。事实上并非如此,但读者不会知道这一点。为了安全起见,他们最终可能会在其他测试中使用此密码。魔法值往往会在测试中自我传播。

  • jhGGu78! seems to be an obvious password value, but the big question I’ll have as a reader is, why this specific value? What’s important about this specific password? It’s obviously important to use this value and not any other for this test, because it seems so damned specific. In reality it isn’t, but the reader won’t know this. They’ll likely end up using this password in other tests just to be safe. Magic values tend to propagate themselves in tests.

以下清单显示了固定魔法值的相同测试。

The following listing shows the same test with the magic values fixed.

清单 9.4 修复魔法值

Listing 9.4 Fixing magic values

描述(“verifier2 - 虚拟对象”,()=> {
  test("周末,抛出异常", () => {
    const SUNDAY = 0, NO _规则 = []; 
    期望(()=> verifyPassword2(“任何东西”,没有_规则,周日))
      .toThrowError("周末了!");
  });
});
describe("verifier2 - dummy object", () => {
  test("on weekends, throws exceptions", () => {
    const SUNDAY = 0, NO_RULES = [];
    expect(() => verifyPassword2("anything", NO_RULES, SUNDAY))
      .toThrowError("It's the weekend!");
  });
});

通过将神奇值放入有意义的命名变量中,我们可以消除人们在阅读我们的测试时会产生的疑问。对于密码值,我决定简单地更改直接值,以向读者解释此测试中哪些内容不重要。

By putting magic values into meaningfully named variables, we can remove the questions people will have when reading our test. For the password value, I’ve decided to simply change the direct value to explain to the reader what is not important about this test.

变量名和值不仅向读者解释什么是重要的,还向读者解释了他们应该关心的内容。

Variable names and values are just as much about explaining to the reader what they should not care about as they are about explaining what is important.

9.3 将断言与动作分开

9.3 Separating asserts from actions

为了可读性和所有神圣的目的,避免在同一个语句中编写断言和方法调用。下面的清单显示了我的意思。

For the sake of readability and all that is holy, avoid writing assertions and the method call in the same statement. The following listing shows what I mean.

清单 9.5 将断言与操作分开

Listing 9.5 Separating asserts from actions

Expect(verifier.verify("任意值")[0]).toContain("假原因");   
 
 
const result = verifier.verify("任何值");                        ❷expect 
(result[0]).toContain("假原因");                         
expect(verifier.verify("any value")[0]).toContain("fake reason");   
 
 
const result = verifier.verify("any value");                        
expect(result[0]).toContain("fake reason");                         

坏榜样

Bad example

好榜样

Good example

看到两个例子之间的区别了吗?由于行的长度以及行为和断言部分的嵌套,第一个示例在实际测试的上下文中更难以阅读和理解。

See the difference between the two examples? The first example is much harder to read and understand in the context of a real test because of the length of the line and the nesting of the act and assert parts.

如果您想在调用后关注结果值,那么调试第二个示例也比第一个示例容易得多。不要吝惜这个小技巧。当你的测试没有让你后面的人因为不理解它而感到愚蠢时,他们会低声说一声谢谢。

It’s also much easier to debug the second example than the first one, if you wanted to focus on the result value after the call. Don’t skimp on this small tip. The people after you will whisper a small thank you when your test doesn’t make them feel stupid for not understanding it.

9.4 设置和拆除

9.4 Setting up and tearing down

单元测试中的设置和拆卸方法可能会被滥用,导致测试或设置和拆卸方法变得不可读。setup方法中的情况通常比teardown方法中的情况更糟糕。

Setup and teardown methods in unit tests can be abused to the point where the tests or the setup and teardown methods are unreadable. The situation is usually worse in the setup method than in the teardown method.

以下清单显示了一种非常常见的可能滥用:使用设置(或beforeEach函数)来设置模拟或桩。

The following listing shows one possible abuse that is very common: using the setup (or beforeEach function) for setting up mocks or stubs.

清单 9.6 使用 setup ( beforeEach) 函数进行模拟设置

Listing 9.6 Using a setup (beforeEach) function for mock setup

描述(“密码验证器”,()=> {
  让模拟日志;
  之前(()=> {
    mockLog = Substitute.for<IComplicatedLogger>();         
  });
 
  test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
    const verifier = new PasswordVerifier2([], mockLog );    
    verifier.verify("任何东西");
 
    mockLog.received ().info(                                 
      Arg.is((x) => x.includes("通过")),
      “核实”
    );
  });
});
describe("password verifier", () => {
  let mockLog;
  beforeEach(() => {
    mockLog = Substitute.for<IComplicatedLogger>();         
  });
 
  test("verify, with logger & passing, calls logger with PASS",() => {
    const verifier = new PasswordVerifier2([], mockLog);    
    verifier.verify("anything");
 
    mockLog.received().info(                                
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });
}); 

设置模拟

Setting up a mock

使用模拟

Using the mock

如果您在设置方法中设置模拟和桩,则意味着它们不会在实际测试中设置。反过来,这意味着无论谁正在阅读您的测试,都可能没有意识到正在使用模拟对象,或者测试对它们的期望。

If you set up mocks and stubs in a setup method, that means they don’t get set up in the actual test. That, in turn, means that whoever is reading your test may not even realize that there are mock objects in use, or what the test expects from them.

清单 9.6 中的测试使用了该变量,该变量在函数(设置方法)mockLog中初始化。beforeEach想象一下您的文件中有数十个或更多这样的测试。设置函数位于文件的开头,而您只能在文件中向下读取测试路径。当你遇到这个mockLog变量时,你必须开始问这样的问题:“这个变量是在哪里初始化的?它在测试中会表现如何?” 和更多。

The test in listing 9.6 uses the mockLog variable, which is initialized in the beforeEach function (a setup method). Imagine you have dozens or more of these tests in the file. The setup function is at the beginning of the file, and you are stuck reading a test way down in the file. You come across the mockLog variable and you have to start asking questions such as, “Where is this initialized? How will it behave in the test?” and more.

如果在同一文件中的各种测试中使用多个模拟和桩,则可能出现的另一个问题是设置函数成为测试使用的所有各种状态的转储组。它变得一团糟,一堆参数,一些被一个测试使用,另一些在其他地方使用。管理和理解这样的设置变得很困难。

Another problem that can arise if multiple mocks and stubs are used in various tests in the same file is that the setup function becomes a dumping group for all the various states used by your tests. It becomes a big mess, a soup of many parameters, some used by one test and others used somewhere else. It becomes difficult to manage and understand such a setup.

直接在测试中初始化模拟对象更具可读性,符合他们的所有期望。以下清单是在每个测试中初始化模拟的示例。

It’s much more readable to initialize mock objects directly in the test, with all their expectations. The following listing is an example of initializing the mock in each test.

清单 9.7 避免设置函数

Listing 9.7 Avoiding a setup function

描述(“密码验证器”,()=> {
  test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
    const mockLog = Substitute.for<IComplicatedLogger>();             
 
    constverifier=newPasswordVerifier2([],mockLog);
    verifier.verify("任何东西");
 
    mockLog.received().info(
      Arg.is((x) => x.includes("通过")),
      “核实”
    );
  });
describe("password verifier", () => {
  test("verify, with logger & passing,calls logger with PASS",() => {
    const mockLog = Substitute.for<IComplicatedLogger>();             
 
    const verifier = new PasswordVerifier2([], mockLog);
    verifier.verify("anything");
 
    mockLog.received().info(
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });

在测试中初始化mock

Initializing the mock in the test

当我看到这个测试时,一切都一目了然。我可以看到模拟何时创建、其行为以及我需要知道的任何其他信息。

When I look at this test, everything is clear as day. I can see when the mock is created, its behavior, and anything else I need to know.

如果您担心可维护性,可以将模拟的创建重构为每个测试都会调用的辅助函数。这样,您就可以避免使用通用设置函数,而是从多个测试中调用相同的辅助函数。正如下面的清单所示,您可以保持可读性并获得更多的可维护性。

If you’re worried about maintainability, you can refactor the creation of the mock into a helper function that each test would call. That way, you’re avoiding the generic setup function and are instead calling the same helper function from multiple tests. As the following listing shows, you keep the readability and gain more maintainability.

清单 9.8 使用辅助函数

Listing 9.8 Using a helper function

描述(“密码验证器”,()=> {
  test("验证,使用记录器并通过,使用 PASS 调用记录器",() => {
    const mockLog = makeMockLogger();                                 
 
    constverifier=newPasswordVerifier2([],mockLog);
    verifier.verify("任何东西");
 
    mockLog.received().info(
      Arg.is((x) => x.includes("通过")),
      “核实”
    );
  });
describe("password verifier", () => {
  test("verify, with logger & passing,calls logger with PASS",() => {
    const mockLog = makeMockLogger();                                 
 
    const verifier = new PasswordVerifier2([], mockLog);
    verifier.verify("anything");
 
    mockLog.received().info(
      Arg.is((x) => x.includes("PASSED")),
      "verify"
    );
  });

使用辅助函数来初始化模拟

Using a helper function to initialize the mock

是的,如果您遵循这个逻辑,您会发现我完全同意您在测试中没有任何设置功能。为了可维护性,我经常编写没有设置函数的完整测试套件,而是从每个测试中调用辅助方法。测试仍然具有可读性和可维护性。

And yes, if you follow this logic, you can see that I’m perfectly OK with you not having any setup functions in your tests. I’ve often written full test suites that don’t have a setup function, instead calling helper methods from each test, for the sake of maintainability. The tests were still readable and maintainable.

概括

Summary

  • 命名测试时,请包括被测工作单元的名称、当前测试场景以及工作单元的预期行为。

  • When naming a test, include the name of the unit of work under test, the current test scenario, and the expected behavior of the unit of work.

  • 不要在测试中留下魔法值。将它们包装在具有有意义名称的变量中,或者将描述放入值本身(如果它是字符串)。

  • Don’t leave magic values in your tests. Either wrap them in variables with meaningful names, or put the description into the value itself, if it’s a string.

  • 将断言与行动分开。将两者合并可以缩短代码,但会使其更难理解。

  • Separate assertions from actions. Merging the two shortens the code but makes it significantly harder to understand.

  • 尽量不要使用测试设置(例如beforeEach方法)。引入辅助方法来简化测试的排列部分,并在每个测试中使用这些辅助方法。

  • Try not to use test setups at all (such as beforeEach methods). Introduce helper methods to simplify the test’s arrange part, and use those helper methods in each test.

10 制定测试策略

10 Developing a testing strategy

本章涵盖

This chapter covers

  • 测试级别的优缺点
  • Testing level pros and cons
  • 测试级别中的常见反模式
  • Common antipatterns in test levels
  • 测试配方策略
  • The test recipe strategy
  • 传递阻塞和非阻塞测试
  • Delivery-blocking and non-blocking tests
  • 交付与发现管道
  • Delivery vs. discovery pipelines
  • 测试并行化
  • Test parallelization

单元测试仅代表您可以并且应该编写的测试类型之一。在本章中,我们将讨论单元测试如何适应组织测试策略。一旦我们开始研究其他类型的测试,我们就开始提出一些非常重要的问题:

Unit tests represent just one of the types of tests you could and should write. In this chapter, we’ll discuss how unit testing fits into an organizational testing strategy. As soon as we start to look at other types of tests, we start asking some really important questions:

  • 我们想在什么级别测试各种功能?(UI、后端、API、单元等)

  • At what level do we want to test various features? (UI, backend, API, unit, etc.)

  • 我们如何决定在哪个级别测试某个功能?我们是否在多个层面上多次测试它?

  • How do we decide at which level to test a feature? Do we test it multiple times on many levels?

  • 我们应该进行更多的功能性端到端测试还是更多的单元测试?

  • Should we have more functional end-to-end tests or more unit tests?

  • 我们如何在不牺牲对测试的信任的情况下优化测试速度?

  • How can we optimize the speed of tests without sacrificing trust in them?

  • 谁应该编写每种类型的测试?

  • Who should write each type of test?

这些问题以及更多问题的答案就是我所说的测试策略

The answers to these questions, and many more, are what I’d call a testing strategy.

我们旅程的第一步是根据测试类型确定测试策略的范围。

The first step in our journey is to frame the scope of the testing strategy in terms of test types.

10.1 常见测试类型和级别

10.1 Common test types and levels

不同的行业可能有不同的测试类型和级别。我们在第 7 章中首次讨论的图 10.1 是一组相当通用的测试类型,我认为它适合我咨询过的 90%(甚至更多)的组织。测试的级别越高,它们使用的依赖项就越真实,这让我们对整个系统的正确性充满信心。缺点是此类测试速度较慢且不稳定。

Different industries might have different test types and levels. Figure 10.1, which we first discussed in chapter 7, is a rather generic set of test types that I feel fits 90% of the organizations I consult with, if not more. The higher the level of the tests, the more real dependencies they use, which gives us confidence in the overall system’s correctness. The downside is that such tests are slower and flakier.

10-01



图10.1 常见的软件测试级别

Figure 10.1 Common software test levels

很好的图表,但是我们用它做什么呢?当我们设计一个框架来决定要编写哪个测试时,我们会使用它。我喜欢指出几个标准(使我们的工作变得更容易或更困难的因素);这些帮助我决定使用哪种测试类型。

Nice diagram, but what do we do with it? We use it when we design a framework for decision making about which test to write. There are several criteria (things that make our jobs easier or harder) I like to pinpoint; these help me decide which test type to use.

10.1.1 测试判定标准

10.1.1 Criteria for judging a test

当我们面临两个以上的选择时,我发现帮助我做出决定的最好方法之一就是弄清楚我对当前问题的明显价值观是什么。这些明显的价值观是我们在做出选择时几乎都同意有用或应该避免的事情。表 10.1 列出了我的明显测试值。

When we’re faced with more than two options to choose from, one of the best ways I’ve found to help me decide is to figure out what my obvious values are for the problem at hand. These obvious values are the things we can all pretty much agree are useful or should be avoided when making the choice. Table 10.1 lists my obvious values for tests.

表 10.1 通用测试记分卡

Table 10.1 Generic test scorecard

标准

Criterion

评定量表

Rating scale

笔记

Notes

复杂

Complexity

1-5

1-5

测试的编写、读取或调试有多复杂。越低越好。

How complicated a test is to write, read, or debug. Lower is better.

片状

Flakiness

1-5

1-5

测试因无法控制的因素(来自其他组、网络、数据库、配置等)而失败的可能性有多大。越低越好。

How likely a test is to fail because of things it does not control—code from other groups, networks, databases, configuration, and more. Lower is better.

过去时的信心

Confidence when passes

1-5

1-5

当测试通过时,我们的头脑和内心会产生多少信心。越高越好。

How much confidence is generated in our minds and hearts when a test passes. Higher is better.

可维护性

Maintainability

1-5

1-5

测试需要更改的频率以及更改的容易程度。越高越好。

How often the test needs to change, and how easy it is to change. Higher is better.

执行速度

Execution speed

1-5

1-5

测试多快完成?越高越好。

How quickly does the test finish? Higher is better.

所有值都从 1 到 5 缩放。正如您将看到的,图 10.1 中的每个级别在每个标准中都有优点和缺点。

All values are scaled from 1 to 5. As you’ll see, each level in figure 10.1 has pros and cons in each of these criteria.

10.1.2 单元测试和组件测试

10.1.2 Unit tests and component tests

单元测试和组件测试是我们进行过的测试类型本书到目前为止所讨论的内容。它们都属于同一类别,唯一的区别是组件测试可能有更多的函数、类或组件作为工作单元的一部分。换句话说,组件测试在入口点和出口点之间包含更多“东西”。

Unit tests and component tests are the types of tests we’ve been discussing in this book so far. They both fit under the same category, with the only differentiation being that component tests might have more functions, classes, or components as part of the unit of work. In other words, component tests include more “stuff” between the entry and exit points.

下面通过两个测试示例来说明差异:

Here are two test examples to illustrate the difference:

  • 测试 A — 内存中自定义 UI 按钮对象的单元测试。您可以实例化它,单击它,然后查看它触发某种形式的单击事件。

  • Test A—A unit test of a custom UI button object in memory. You can instantiate it, click it, and see that it triggers some form of click event.

  • 测试 B — 实例化更高级别表单组件并将按钮作为其结构一部分的组件测试。该测试验证了更高级别的表单,按钮在更高级别的场景中扮演了一个小角色。

  • Test B—A component test that instantiates a higher-level form component and includes the button as part of its structure. The test verifies the higher-level form, with the button playing a small role as part of the higher-level scenario.

这两个测试仍然是内存中的单元测试,我们可以完全控制正在使用的所有东西;不依赖于文件、数据库、网络、配置或我们无法控制的其他事物。测试 A 是较低级别的单元测试,测试 B 是组件测试,或者较高级别的单元测试。

Both tests are still unit tests, in memory, and we have full control over all the things being used; there are no dependencies on files, databases, networks, configuration, or other things we don’t control. Test A is a lower-level unit test, and test B is a component test, or a higher-level unit test.

需要进行这种区分的原因是因为我经常被问到什么是具有不同抽象级别的测试。答案是,测试是否属于单元/组件测试类别取决于它具有或不具有的依赖关系,而不是基于它使用的抽象级别。表 10.2 显示了单元/组件测试层的记分卡。

The reason this differentiation needs to be made is because I often get asked what I would call a test with a different level of abstraction. The answer is that whether a test falls into the unit/component test category is based on the dependencies it does or doesn’t have, not on the abstraction level it uses. Table 10.2 shows the scorecard for the unit/component test layer.

表 10.2 单元/组件测试记分卡

Table 10.2 Unit/component test scorecard

复杂

Complexity

1/5

1/5

由于范围较小并且我们可以控制测试中的所有内容,因此它们是所有测试类型中最简单的。

These are the least complex of all test types due to the smaller scope and the fact that we can control everything in the test.

片状

Flakiness

1/5

1/5

这些是所有测试类型中最不稳定的,因为我们可以控制测试中的一切。

These are the least flaky of all test types, since we can control everything in the test.

过去时的信心

Confidence when passes

1/5

1/5

当单元测试通过时感觉很好,但我们对我们的应用程序的工作并不真正有信心。我们只知道其中一小部分确实如此。

It feels nice when a unit test passes, but we’re not really confident that our application works. We just know that a small piece of it does.

可维护性

Maintainability

5/5

5/5

这些是所有测试类型中最容易维护的,因为它相对容易阅读和推理。

These are the easiest to maintain out of all test types, since it’s relatively simple to read and to reason about.

执行速度

Execution speed

5/5

5/5

这是所有测试类型中最快的,因为所有内容都在内存中运行,而没有对文件、网络或数据库的任何硬依赖。

These are the fastest of all test types, since everything runs in memory without any hard dependencies on files, network, or databases.

10.1.3 集成测试

10.1.3 Integration tests

集成测试看起来几乎与常规单元测试一模一样,但某些依赖项并未被消除。例如,我们可能使用真实的配置、真实的数据库、真实的文件系统或全部三者。但为了调用测试,我们仍然从内存中的生产代码实例化一个对象,并直接在该对象上调用入口点函数。表 10.3 显示了集成测试的记分卡。

Integration tests look almost exactly like regular unit tests, but some of the dependencies are not stubbed out. For example, we might use a real configuration, a real database, a real filesystem, or all three. But to invoke the test, we still instantiate an object from our production code in memory and invoke an entry point function directly on that object. Table 10.3 shows the scorecard for integration tests.

表 10.3 集成测试记分卡

Table 10.3 Integration test scorecard

复杂

Complexity

2/5

2/5

这些测试稍微复杂一些或复杂得多,具体取决于我们在测试中不伪造的依赖项数量。

These tests are slightly or greatly more complex, depending on the number of dependencies that we do not fake in the test.

片状

Flakiness

2-3/5

2-3/5

这些测试稍微或更加不稳定,具体取决于我们使用的实际依赖项的数量。

These tests are slightly or much flakier depending on how many real dependencies we use.

过去时的信心

Confidence when passes

2-3/5

2-3/5

当集成测试通过时感觉好多了,因为我们正在验证代码是否使用了我们无法控制的东西,例如数据库或配置文件。

It feels much better when an integration test passes because we are verifying that the code uses something we do not control, like a database or a config file.

可维护性

Maintainability

3-4/5

3-4/5

由于依赖性,这些测试比单元测试更复杂。

These tests are more complex than a unit test because of the dependencies.

执行速度

Execution speed

3-4/5

3-4/5

由于对文件系统、网络、数据库或线程的依赖性,这些测试比单元测试稍慢或慢得多。

These tests are slightly or much slower than a unit test because of the dependency on the filesystem, network, database, or threads.

10.1.4 API测试

10.1.4 API tests

在之前较低级别的测试中,我们不需要部署被测应用程序或使其正确运行来进行测试。在API测试级别,我们最终需要部署(至少部分)被测应用程序并通过网络调用它。与单元、组件和集成测试(可归类为内存内测试)不同,API 测试是进程外测试。我们不再直接在内存中实例化被测单元。这意味着我们要在组合中添加一个新的依赖项:网络以及某些网络服务的部署。表 10.4 显示了 API 测试的记分卡。

In previous lower levels of tests, we haven’t needed to deploy the application under test or make it properly run to test it. At the API test level, we finally need to deploy, at least in part, the application under test and invoke it through the network. Unlike unit, component, and integration tests, which can be categorized as in-memory tests, API tests are out-of-process tests. We are no longer instantiating the unit under test directly in memory. This means we’re adding a new dependency into the mix: a network, as well as the deployment of some network service. Table 10.4 shows the scorecard for API tests.

表 10.4 API 测试记分卡

Table 10.4 API test scorecard

复杂

Complexity

3/5

3/5

这些测试稍微复杂一些或复杂得多,具体取决于所需的部署复杂性、配置和 API 设置。有时我们需要在测试中包含 API 模式,这需要额外的工作和思考。

These tests are slightly or greatly more complex, depending on the deployment complexity, configuration, and API setup needed. Sometimes we need to include the API schema in the test, which takes extra work and thinking.

片状

Flakiness

3-4/5

3-4/5

网络给这种组合增添了更多的不稳定因素。

The network adds more flakiness to the mix.

过去时的信心

Confidence when passes

3-4/5

3-4/5

当 API 测试通过时,感觉会更好。我们可以相信,部署后其他人可以放心地调用我们的 API。

It feels even better when an API test passes. We can trust that others can call our API with confidence after deployment.

可维护性

Maintainability

2-3/5

2-3/5

网络增加了更多的设置复杂性,并且在更改测试或添加/更改 API 时需要更加小心。

The network adds more setup complexity and needs more care when changing a test or adding/changing APIs.

执行速度

Execution speed

2-3/5

2-3/5

网络大大减慢了测试速度。

The network slows the tests down considerably.

10.1.5 E2E/UI隔离测试

10.1.5 E2E/UI isolated tests

在端到端隔离级别(E2E)和用户界面(UI)测试,我们从用户的角度测试我们的应用程序。我使用“隔离”一词来指定我们测试我们自己的应用程序或服务,而不部署我们的应用程序可能需要的任何依赖应用程序或服务。此类测试会伪造第三方身份验证机制、需要部署在同一服务器上的其他应用程序的 API,以及任何不属于被测主应用程序的特定代码(包括来自同一组织其他部门的应用程序)这些也会被伪造)。

At the level of isolated end-to-end (E2E) and user interface (UI) tests, we are testing our application from the point of view of a user. I use the word isolated to specify that we are testing only our own application or service, without deploying any dependency applications or services that our application might need. Such tests fake third-party authentication mechanisms, the APIs of other applications that are required to be deployed on the same server, and any code that is not specifically a part of the main application under test (including apps from the same organization’s other departments—those would be faked as well).

表 10.5 显示了 E2E/UI 隔离测试的记分卡。

Table 10.5 shows the scorecard for E2E/UI isolated tests.

表10.5 E2E/UI隔离测试记分卡

Table 10.5 E2E/UI isolated test scorecard

复杂

Complexity

4/5

4/5

这些测试比以前的测试复杂得多,因为我们正在处理用户流、基于 UI 的更改以及捕获或抓取 UI 以进行集成和断言。等待和超时比比皆是。

These tests are much more complex than previous tests, since we are dealing with user flows, UI-based changes, and capturing or scraping the UI for integration and assertions. Waiting and timeouts abound.

片状

Flakiness

4/5

4/5

由于涉及许多依赖项,测试可能会减慢、超时或无法工作的原因有很多。

There are lots of reasons the test may slow down, time out, or not work due to the many dependencies involved.

过去时的信心

Confidence when passes

4/5

4/5

当这种类型的测试通过时,这是一个巨大的缓解。我们对我们的应用程序充满信心。

It’s a huge relief when this type of test passes. We gain a lot of confidence in our application.

可维护性

Maintainability

1-2/5

1-2/5

更多依赖项会增加设置的复杂性,并且在更改测试或添加或更改工作流程时需要更加小心。测试很长并且通常有多个步骤。

More dependencies add more setup complexity and require more care when changing a test or adding or changing workflows. Tests are long and usually have multiple steps.

执行速度

Execution speed

1-2/5

1-2/5

当我们浏览用户界面时,这些测试可能会非常慢,有时包括登录、缓存、多页面导航等。

These tests can be very slow as we navigate user interfaces, sometimes including logins, caching, multipage navigation, etc.

10.1.6 E2E/UI系统测试

10.1.6 E2E/UI system tests

在系统 E2E 和 UI 测试层面,没有什么是假的。这与我们所能得到的生产部署非常接近:所有依赖应用程序和服务都是真实的,但它们可能会进行不同的配置以允许我们的测试场景。表 10.6 显示了 E2E/UI 系统测试的记分卡。

At the level of system E2E and UI tests nothing is fake. This is as close to a production deployment as we can get: all dependency applications and services are real, but they might be differently configured to allow for our testing scenarios. Table 10.6 shows the scorecard for E2E/UI system tests.

表10.6 E2E/UI系统测试记分卡

Table 10.6 E2E/UI system test scorecard

复杂

Complexity

5/5

5/5

由于依赖项数量众多,这些是设置和编写的最复杂的测试。

These are the most complex tests to set up and write due to the number of dependencies.

片状

Flakiness

5/5

5/5

这些测试可能因数千种不同原因中的任何一种而失败,而且通常是出于多种原因。

These tests can fail for any of thousands of different reasons, and often for multiple reasons.

过去时的信心

Confidence when passes

5/5

5/5

这些测试给了我们最高的信心,因为测试执行时所有代码都会被测试。

These tests give us the highest confidence because of all the code that gets tested when the tests execute.

可维护性

Maintainability

1/5

1/5

由于许多依赖项和较长的工作流程,这些测试很难维护。

These tests are hard to maintain, due to the many dependencies and long workflows.

执行速度

Execution speed

1/5

1/5

这些测试非常慢,因为它们使用 UI 和真实的依赖项。一次测试可能需要几分钟到几个小时。

These tests are very slow because they use the UI and real dependencies. They can take minutes to hours for a single test.

10.2 测试级反模式

10.2 Test-level antipatterns

测试级反模式本质上不是技术性的,而是组织性的。您可能亲眼见过它们。作为顾问,我可以告诉你,它们非常普遍

Test-level antipatterns are not technical but organizational in nature. You’ve likely seen them firsthand. As a consultant, I can tell you that they are very prevalent.

10.2.1 仅端到端反模式

10.2.1 The end-to-end-only antipattern

组织的一个非常常见的策略是主要使用(如果不是唯一的话)E2E 测试(隔离测试和系统测试)。图 10.2 显示了测试级别和类型图中的情况。

A very common strategy that an organization will have is using mostly, if not only, E2E tests (both isolated and system tests). Figure 10.2 shows what this looks like in the diagram of test levels and types.

10-02



图 10.2 仅端到端测试反模式

Figure 10.2 End-to-end-only test antipattern

为什么这是反模式?这个级别的测试非常慢,难以维护,难以调试,而且非常不稳定。这些成本保持不变,而您从每个新的 E2E 测试中获得的价值却在减少。

Why is this an antipattern? Tests at this level are very slow, hard to maintain, hard to debug, and very flaky. These costs remain the same, while the value you get from each new E2E test diminishes.

E2E 测试的回报递减

Diminishing returns from E2E tests

您编写的第一个 E2E 测试将为您带来最大的信心,因为该场景中包含了许多其他代码路径,并且因为粘合剂(编排应用程序和其他系统之间的工作的代码)被调用为该测试的一部分。

The first E2E test you write will bring you the most confidence because of how many other paths of code are included as part of that scenario, and because of the glue—the code orchestrating the work between your application and other systems—that gets invoked as part of that test.

但是第二个E2E测试呢?它通常是第一次测试的变化,这意味着它可能只会带来相同值的一小部分。也许组合框和其他 UI 元素有所不同,但所有依赖项(例如数据库和第三方系统)保持不变。

But what about the second E2E test? It will usually be a variation on the first test, which means it might only bring a small fraction of the same value. Maybe there’s a difference in a combo box and other UI elements, but all the dependencies, such as the database and third-party systems, remain the same.

您从第二次E2E 测试中获得的额外置信度也只是您从第一次 E2E 测试中获得的额外置信度的一小部分。然而,调试、更改、读取和运行该测试的成本并不是一小部分;与之前的测试基本相同。为了一点额外的信心,你需要付出大量的额外工作,这就是为什么我喜欢说 E2E 测试的回报很快就会递减。

The amount of extra confidence you get from the second E2E test is also only a fraction of the extra confidence you got from the first E2E test. However, the cost of debugging, changing, reading, and running that test is not a fraction; it is basically the same as for the previous test. You’re incurring a lot of extra work for a very small bit of extra confidence, which is why I like to say that E2E tests have quickly diminishing returns.

如果我想要第一次测试有变化,那么在比前一次测试更低的水平上进行测试会更加务实。从第一次测试开始,我就已经知道了大多数(如果不是全部)层间粘合的作用。如果我能够以较低的水平证明下一个场景,并且在几乎相同的信心下支付更少的费用,那么就没有必要支付另一次 E2E 测试的税。

If I want variation on the first test, it would be much more pragmatic to test at a lower level than the previous test. I already know most, if not all, of the glue between layers works, from the first test. There’s no need to pay the tax of another E2E test if I can prove the next scenario at a lower level and pay a much smaller fee for pretty much the same bit of confidence.

构建耳语者

The build whisperer

通过端到端测试,我们不仅收益递减,而且在组织中造成了新的瓶颈。由于高级测试通常很不稳定,因此它们会因许多不同的原因而失败,其中一些原因与测试无关。然后,您需要组织中的特殊人员(通常是 QA 领导)坐下来分析许多失败的测试中的每一个,并找出原因并确定它是否确实是一个问题或一个小问题。

With E2E tests, not only do we have diminishing returns, we create a new bottleneck in the organization. Because high-level tests are often flaky, they break for many different reasons, some of which are not relevant to the test. You then need special people in the organization (usually QA leads) to sit down and analyze each of the many failing tests, and to hunt down the cause and determine if it’s actually a problem or a minor issue.

我称这些可怜的灵魂为“低语者”。当构建为红色时(大多数情况下都是如此),构建耳语者必须进来,解析数据,并在经过数小时的检查后有意识地说,“是的,它看起来是红色的,但实际上是绿色的。”

I call these poor souls build whisperers. When the build is red, which it is most of the time, build whisperers are the ones who must come in, parse the data, and knowingly say, after hours of inspection, “Yes, it looks red, but it’s actually green.”

通常,该组织会将构建低语者逼到角落,要求他们说构建是绿色的,因为“我们必须将此版本发布出去”。他们是发布的看门人,这是一项吃力不讨好、压力很大、而且常常是体力劳动且令人沮丧的工作。窃窃私语者通常会在一两年内精疲力竭,他们会被咀嚼并被吐到下一个组织,在那里他们会再次做同样吃力不讨好的工作。当许多高级 E2E 测试的这种反模式存在时,您经常会看到构建耳语者。

Usually, the organization will drive build whisperers into a corner, demanding that they say the build is green because “We have to get this release out the door.” They are the gatekeepers of the release, and that is a thankless, stressful, and often manual and frustrating job. Whisperers usually burn out within a year or two, and they get chewed up and spit out into the next organization, where they do the same thankless job all over again. You’ll often see build whisperers when this antipattern of many high-level E2E tests exists.

避免构建耳语者

Avoiding build whisperers

有一种方法可以解决这个混乱,那就是创建和培养强大的自动化测试管道,即使您的测试不可靠,也可以自动判断构建是否是绿色的。Netflix 公开发布了博客,介绍如何创建自己的工具来衡量构建在野外的统计表现,以便可以自动批准其进行完整发布部署 (http://mng.bz/BAA1)。这是可行的,但需要时间和文化来实现这样的管道。我在我的博客 https://pipelinedriven.org 中撰写了有关这些类型管道的更多信息。

There is a way to resolve this mess, and that’s to create and cultivate robust, automated test pipelines that can automatically judge whether a build is green or not, even if you have flaky tests. Netflix has openly blogged about creating their own tool for measuring how a build is doing statistically in the wild, so that it can be automatically approved for full release deployment (http://mng.bz/BAA1). This is doable, but it takes time and culture to achieve such a pipeline. I write more about these types of pipelines in my blog at https://pipelinedriven.org.

“把它扔过墙”的心态

A “throw it over the wall” mentality

仅进行 E2E 测试对组织造成伤害的另一个原因是,负责维护和监控这些测试的人员是 QA 部门的人员。这意味着组织的开发人员可能不关心甚至不知道这些构建的结果,并且他们不会投资于修复或关心这些测试。他们不拥有它们。

Another reason having only E2E tests hurts organizations is that the people in charge of maintaining and monitoring these tests are people in the QA department. This means that the organization’s developers might not care about or even know the results of these builds, and they are not invested in fixing or caring for these tests. They don’t own them.

这种“把事情扔到墙上”的心态可能会导致许多沟通不畅和质量问题,因为组织的一方与其行为的后果无关,而另一方则在无法控制行为来源的情况下承受后果。问题。在许多组织中,开发人员和 QA 人员相处不好,这有什么奇怪的吗?他们周围的制度往往被设计成让他们成为不共戴天的敌人而不是合作者。

This “throw it over the wall” mentality can cause lots of miscommunication and quality issues because one part of the organization is not connected to the consequences of its actions, and the other side is suffering the consequences without being able to control the source of the issue. Is it any wonder that, in many organizations, developers and QA people don’t get along? The system around them is often designed to make them mortal enemies instead of collaborators.

当这种反模式发生时

When this antipattern happens

以下是我看到这种情况发生的一些原因:

These are some reasons why I see this happen:

  • 职责分离——许多组织中都存在具有独立管道(自动构建作业和仪表板)的独立 QA 和开发部门。当 QA 部门拥有自己的管道时,它可能会编写更多同类测试。此外,质量保证部门倾向于只编写特定类型的测试 - 他们习惯并期望编写的测试(有时基于公司政策)。

  • Separation of duties—Separate QA and development departments with separate pipelines (automated build jobs and dashboards) exist in many organizations. When a QA department has its own pipeline, it is likely to write more tests of the same kind. Also, a QA department tends to write only a specific type of test—the ones they’re used to and are expected to write (sometimes based on company policy).

  • “如果有效,就不要改变它”的心态——团队可能会从端到端测试开始,并看到他们喜欢结果。他们继续以相同的方式添加所有新测试,因为这是他们所知道的,并且已被证明是有用的。当运行测试所需的时间变得太长时,改变方向就已经太晚了(这与下一点有关)。

  • An “if it works, don’t change it” mentality—A group might start with E2E tests and see that they like the results. They continue to add all their new tests in the same way, because it’s what they know, and it has proven to be useful. When the time it takes to run tests gets too long, it’s already too late to change direction (which relates to the next point).

  • 沉没成本谬误——“我们有很多此类测试,如果我们更改它们或用较低级别的测试替换它们,则意味着我们在要删除的测试上浪费了所有时间和精力。” 这是一个谬论,因为维护、调试和理解测试失败会花费大量的人力时间。如果有什么不同的话,那就是删除此类测试(仅保留一些基本场景)并收回时间的成本更低。

  • Sunk-costs fallacy—“We have lots of these types of tests, and if we changed them or replaced them with lower-level tests, it would mean we’ve wasted all that time and effort on tests that we are removing.” This is a fallacy, because maintaining, debugging, and understanding test failures costs a fortune in human time. If anything, it costs less to delete such tests (keeping only a few basic scenarios) and get that time back.

您应该完全避免 E2E 测试吗?

Should you avoid E2E tests completely?

不,我们无法避免端到端测试。好的之一他们提供的是对应用程序运行的信心。与单元测试相比,这是完全不同的置信度,因为它们从用户的角度测试整个系统及其所有子系统和组件的集成。当它们过去时,您会感到非常轻松,因为您期望用户遇到的主要场景确实有效。

No, we can’t avoid E2E tests. One of the good things they offer is confidence that the application works. It’s a completely different level of confidence compared to unit tests, because they test the integration of the full system, with all of its subsystems and components, from the point of view of a user. When they pass, the feeling you get is huge relief that the major scenarios you expect your users to encounter actually work.

所以不要完全避开它们。相反,我强烈建议尽量减少E2E 测试的数量。我们将在第 10.3.3 节中讨论该最小值是多少。

So don’t avoid them entirely. Instead, I highly recommend minimizing the number of E2E tests. We’ll talk about what that minimum is in section 10.3.3.

10.2.2 仅低级测试反模式

10.2.2 The low-level-only test antipattern

与仅进行 E2E 测试相反的是仅进行低级测试。单元测试提供快速反馈,但它们无法提供完全信任您的应用程序作为单个集成单元运行所需的信心(见图 10.3)。

The opposite of having only E2E tests is to have low-level tests only. Unit tests provide fast feedback, but they don’t provide the amount of confidence needed to fully trust that your application works as a single integrated unit (see figure 10.3).

10-03



图 10.3 仅低级测试反模式

Figure 10.3 Low-level-only test antipattern

在这种反模式中,组织的自动化测试主要或完全是低级测试,例如单元测试或组件测试。可能有集成测试的迹象,但目前还没有端到端测试。

In this antipattern, the organization’s automated tests are mostly or exclusively low-level tests, such as unit tests or component tests. There may be hints of integration tests, but there are no E2E tests in sight.

最大的问题是,当这些类型的测试通过时,您获得的置信度不足以让您对应用程序的工作充满信心。这意味着人们将运行测试,然后继续进行手动调试和测试,以获得发布某些内容所需的最终信心。除非您要交付的是一个代码库,该代码库旨在以单元测试使用它的方式使用,否则这还不够。是的,测试将运行得很快,但您仍然会花费大量时间手动测试和验证。

The biggest issue with this is that the confidence level you get when these types of tests pass is simply not enough to feel confident that your application works. That means people will run the tests and then continue to do manual debugging and testing to get the final sense of confidence needed to release something. Unless what you’re shipping is a code library that’s meant to be used in the way your unit tests are using it, this won’t be enough. Yes, the tests will run quickly, but you’ll still spend lots of time manually testing and verifying.

当您的开发人员只习惯编写低级测试(如果他们感觉不舒服)时,通常会发生这种反模式编写高级测试,或者他们希望 QA 人员编写这些类型的测试。

This antipattern often happens when your developers are only used to writing low-level tests, if they don’t feel comfortable writing high-level tests, or if they expect the QA people to write those types of tests.

这是否意味着您应该避免单元测试?很明显不是。但我强烈建议您不仅进行单元测试,还进行更高级别的测试。我们将在 10.3 节中讨论该建议。

Does that mean you should avoid unit tests? Obviously not. But I highly recommend that you have not only unit tests but also higher-level tests. We’ll discuss this recommendation in section 10.3.

10.2.3 断开低级和高级测试

10.2.3 Disconnected low-level and high-level tests

这种模式乍一看似乎很健康,但事实并非如此。它可能看起来有点像图 10.4。

This pattern might seem healthy at first, but it really isn’t. It might look a bit like figure 10.4.

10-04



图10.4 断开的低级和高级测试

Figure 10.4 Disconnected low-level and high-level tests

是的,您想要同时进行低级测试(为了速度)和高级测试(为了信心)。但是,当您在组织中看到类似的情况时,您可能会遇到以下一种或多种反行为:

Yes, you want to have both low-level tests (for speed) and high-level tests (for confidence). But when you see something like this in an organization, you will likely encounter one or more of these anti-behaviors:

  • 许多测试在多个级别上重复。

  • Many of the tests repeat in multiple levels.

  • 编写低级测试的人与编写高级测试的人不同。这意味着他们不关心彼此的测试结果,并且他们可能会使用不同的管道执行不同的测试类型。当一个管道呈红色时,另一组可能甚至不知道也不关心这些测试失败。

  • The people who write the low-level tests are not the same people who write the high-level tests. This means they don’t care about each other’s test results, and they’ll likely have different pipelines execute the different test types. When one pipeline is red, the other group might not even know nor care that those tests are failing.

  • 我们遭受着两全其美的痛苦:在顶层,我们面临着测试时间长、可维护性困难、构建低语者和不稳定的问题;在底层,我们缺乏信心。而且由于经常缺乏沟通,我们无法获得低级测试的速度优势,因为它们无论如何都会在顶部重复。我们也没有得到最高级别的信心,因为如此大量的测试是多么不稳定。

  • We suffer the worst of both worlds: at the top level, we suffer from the long test times, difficult maintainability, build whisperers, and flakiness; at the bottom level, we suffer from lack of confidence. And because there is often a lack of communication, we don’t get the speed benefit of the low-level tests because they repeat at the top anyway. We also don’t get the top-level confidence because of how flaky such a large number of tests is.

当我们有不同的目标和指标,以及不同的作业和管道、权限甚至代码存储库的单独的测试和开发组织时,通常会发生这种模式。公司越大,这种情况发生的可能性就越大。

This pattern often happens when we have separate test and a development organizations with different goals and metrics, as well as different jobs and pipelines, permissions, and even code repositories. The larger the company, the more likely this is to happen.

10.3 测试配方作为策略

10.3 Test recipes as a strategy

我提出的实现组织使用的测试类型平衡的策略是使用测试配方。这个想法是制定一个关于如何测试特定功能的非正式计划。该计划不仅应包括主要场景(也称为“快乐路径”),还应包括其所有显着变化(也称为“边缘情况”),如图 10.5 所示。概述良好的测试方案可以清楚地说明适合每种场景的测试级别。

My proposed strategy to achieve balance in the types of tests used by the organization is to use test recipes. The idea is to have an informal plan for how a particular feature is going to be tested. This plan should include not only the main scenario (also known as the happy path), but also all its significant variations (also known as edge cases), as shown in figure 10.5. A well-outlined test recipe gives a clear picture of what test level is appropriate for each scenario.

10-05



图 10.5 测试配方是一个测试计划,概述了应在哪个级别测试特定功能。

Figure 10.5 A test recipe is a test plan, outlining at which level a particular feature should be tested.

10.3.1 如何编写测试配方

10.3.1 How to write a test recipe

最好至少有两个人创建一个测试配方 - 希望一个人具有开发人员的观点,另一个人具有测试人员的观点。如果没有测试部门,两个开发人员,或者一个开发人员加一个高级开发人员就足够了。将每个场景映射到测试层次结构中的特定级别可能是一项高度主观的任务,因此两双眼睛将有助于检查彼此的隐含假设。

It’s best to have at least two people create a test recipe—hopefully one with a developer’s point of view and one with a tester’s point of view. If there is no test department, two developers, or a developer with a senior developer will suffice. Mapping each scenario to a specific level in the test hierarchy can be a highly subjective task, so two pairs of eyes will help keep each other’s implicit assumptions in check.

食谱本身可以作为额外文本存储在 TODO 列表中,或者作为任务跟踪板上的专题故事的一部分。您不需要单独的工具来规划测试。

The recipes themselves can be stored as extra text in a TODO list or as part of the feature story on the tracking board for the task. You don’t need a separate tool for planning tests.

创建测试配方的最佳时间是在开始开发该功能之前。这样,测试配方就成为该功能“完成”定义的一部分,这意味着在完整的测试配方通过之前该功能尚未完成。

The best time to create a test recipe is just before you start working on the feature. This way, the test recipe becomes part of the definition of “done” for the feature, meaning the feature is not complete until the full test recipe is passing.

当然,食谱可能会随着时间的推移而改变。团队可以从中添加或删除场景。配方不是一个严格的工件,而是一个持续进行中的工作,就像软件开发中的其他一切一样。

Of course, a recipe can change as time goes by. The team can add or remove scenarios from it. A recipe is not a rigid artifact but a continuous work in progress, just like everything else in software development.

测试配方代表了一系列场景,这些场景将让其创建者对该功能的工作“非常有信心”。根据经验,我喜欢测试级别之间的比例为 1:5 或 1:10。对于任何高级别的 E2E 测试,我可能会进行 5 个较低级别的测试。或者,如果您自下而上思考,则假设您有 100 个单元测试。您通常不需要进行超过 10 个集成测试和 1 个 E2E 测试。

A test recipe represents the list of scenarios that will give its creators “pretty good confidence” that the feature works. As a rule of thumb, I like to have a 1 to 5 or 1 to 10 ratio between levels of tests. For any high-level, E2E test, I might have 5 tests at a lower level. Or, if you think bottom-up, say you have 100 unit tests. You usually won’t need to have more than 10 integration tests and 1 E2E test.

不过,不要将测试食谱视为正式的东西。测试配方不是具有约束力的承诺,也不是测试计划软件中的测试用例列表。请勿将其用作公开报告、用户故事或对利益相关者的任何其他类型的承诺。菜谱的核心是一个由 5 到 20 行文本组成的简单列表,详细说明了要以自动化方式进行测试以及测试级别的简单场景。该列表可以更改、添加或删除。将其视为评论。我通常喜欢将其直接放在 Jira 或我正在使用的任何程序的用户故事或功能中。

Don’t treat test recipes as something formal, though. A test recipe is not a binding commitment or a list of test cases in a test-planning piece of software. Don’t use it as a public report, a user story, or any other kind of promise to a stakeholder. At its core, a recipe is a simple list of 5 to 20 lines of text detailing simple scenarios to be tested in an automated fashion and at what level. The list can be changed, added to, or subtracted from. Consider it a comment. I usually like to just put it right in the user story or feature in Jira or whatever program I’m using.

下面是一个可能看起来像这样的示例:

Here’s an example of what one might look like:

用户个人资料功能测试秘诀
 
E2E - 登录、转到个人资料屏幕、更新电子邮件、注销、使用新电子邮件登录、验证个人资料屏幕已更新
 
API - 使用更复杂的数据调用 UpdateProfile API
单元测试 - 使用错误电子邮件检查个人资料更新逻辑
单元测试 - 使用同一电子邮件的配置文件更新逻辑
单元测试 - 配置文件序列化/反序列化
User profile feature testing recipe
 
E2E - Login, go to profile screen, update email, log out, log in with new email, verify profile screen updated
 
API - Call UpdateProfile API with more complicated data
Unit test - Check profile update logic with bad email
Unit test - Profile update logic with same email
Unit test - Profile serialization/deserialization

10.3.2 我什么时候编写和使用测试配方?

10.3.2 When do I write and use a test recipe?

在开始编写功能或用户故事之前,与另一个人坐下来尝试想出各种要测试的场景。讨论应该在哪个级别上最好地测试该场景。这次会议通常不会超过 5 到 15 分钟,之后就开始编码,包括编写测试。(如果您正在进行 TDD,您将从测试开始。)

Just before you start coding a feature or a user story, sit down with another person and try to come up with various scenarios to be tested. Discuss at which level that scenario should be best tested. This meeting will usually be no longer than 5 to 15 minutes, and after it, coding begins, including the writing of the tests. (If you’re doing TDD, you’ll start with the tests.)

在具有自动化或 QA 角色的组织中,开发人员将编写较低级别的测试,而 QA 将专注于编写较高级别的测试,同时进行功能编码。两个人同时工作。一个人不会等待另一个人完成工作才开始编写测试。

In organizations where there are automation or QA roles, the developer will write the lower-level tests, and the QA will focus on writing the higher-level tests, while coding of the feature is taking place. Both people are working at the same time. One does not wait for the other to finish their work before starting to write their tests.

如果您正在使用功能切换,则还应该将它们作为测试的一部分进行检查,以便如果某个功能关闭,则其测试将不会运行。

If you are working with feature toggles, they should also be checked as part of the tests, so that if a feature is off, its tests will not run.

10.3.3 测试配方的规则

10.3.3 Rules for a test recipe

编写测试配方时需要遵循以下几条规则:

There are several rules to follow when writing a test recipe:

  • 更快- 更喜欢在较低级别编写测试,除非高级测试是让您确信该功能有效的唯一方法。

  • Faster—Prefer writing tests at lower levels, unless a high-level test is the only way for you to gain confidence that the feature works.

  • 信心——当你可以告诉自己“如果所有这些测试都通过了,我会对这个功能的工作感觉非常好”时,配方就完成了。如果你不能这么说,那就写更多能让你这么说的场景。

  • Confidence—The recipe is done when you can tell yourself, “If all these tests passed, I’ll feel pretty good about this feature working.” If you can’t say that, write more scenarios that will allow you to say that.

  • 修改——在编码时可以随意添加或删除列表中的测试。只要确保您通知了与您一起制作食谱的其他人即可。

  • Revise—Feel free to add or remove tests from the list as you code. Just make sure you notify the other person you worked with on the recipe.

  • 及时——在开始编码之前编写此秘诀,此时您知道谁将对其进行编码。

  • Just in time—Write this recipe just before starting to code, when you know who is going to code it.

  • 结对——如果可以的话,不要单独写。人们以不同的方式思考,重要的是通过场景进行讨论并相互学习以测试想法和心态。

  • Pair—Don’t write it alone if you can help it. People think in different ways, and it’s important to talk through the scenarios and learn from each other about testing ideas and mindset.

  • 不要在其他功能中重复自己- 如果现有测试已涵盖此场景(可能是先前功能的 E2E 测试),则无需在该级别重复此场景。

  • Don’t repeat yourself from other features—If this scenario is already covered by an existing test (perhaps an E2E test from a previous feature), there is no need to repeat this scenario at that level.

  • 不要在其他层重复自己——尽量不要在多个层重复相同的场景。如果您在 E2E 级别检查是否成功登录,则较低级别的测试应该仅检查该场景的变体(使用不同的提供商登录、登录不成功的结果等)。

  • Don’t repeat yourself from other layers—Try not to repeat the same scenario at multiple levels. If you’re checking a successful login at the E2E level, lower-level tests should only check variations of that scenario (logging in with different providers, unsuccessful login results, etc.).

  • 更多、更快— 一个好的经验法则是最终级别之间的比例至少为 1 比 5(对于一个 E2E 测试,您可能最终会进行 5 个或更多较低级别的测试)。

  • More, faster—A good rule of thumb is to end up with a ratio of at least one to five between levels (for one E2E test, you might end up with five or more lower-level tests).

  • 务实——不需要为给定的功能编写所有级别的测试。某些功能或用户故事可能只需要单元测试。其他的,只有API或E2E测试。基本思想是,如果配方中的所有场景都通过了,那么无论测试的级别如何,您都应该感到有信心。如果情况并非如此,请将场景移动到不同的级别,直到您感到更加自信,而不会牺牲太多的速度或维护负担。

  • Pragmatic—Don’t feel the need to write tests at all levels for a given feature. Some features or user stories might only require unit tests. Others, only API or E2E tests. The basic idea is that, if all the scenarios in the recipe pass, you should feel confidence, regardless of what level they are tested at. If that’s not the case, move the scenarios around to different levels until you feel more confident, without sacrificing too much speed or maintenance burden.

通过遵循这些规则,您将获得快速反馈的好处,因为您的大多数测试都是低级别的,同时不会牺牲信心,因为少数最重要的场景仍然由高级测试覆盖。测试配方方法还允许您通过将场景变化定位在低于主场景的级别来避免测试之间的大部分重复。最后,如果 QA 人员也参与编写测试方案,您将在组织内的人员之间形成新的沟通渠道,这有助于增进对软件项目的相互理解。

By following these rules, you’ll get the benefit of fast feedback, because most of your tests will be low level, while not sacrificing confidence because the few most important scenarios are still covered by high-level tests. The test recipe approach also allows you to avoid most of the repetition between tests by positioning scenario variations at levels lower than the main scenario. Finally, if QA people are involved in writing test recipes too, you’ll form a new communication channel between people within your organization, which helps improve mutual understanding of your software project.

10.4 管理交付管道

10.4 Managing delivery pipelines

性能测试怎么样?安全测试?负载测试?那么许多其他可能需要很长时间才能运行的测试呢?我们应该在何时何地运行它们?它们是哪一层?它们应该成为我们自动化管道的一部分吗?

What about performance tests? Security tests? Load tests? What about lots of other tests that might take ages to run? Where and when should we run them? Which layer are they? Should they be part of our automated pipeline?

许多组织将这些测试作为针对每个版本或拉取请求运行的集成自动化管道的一部分来运行。然而,这会导致反馈的巨大延迟,并且反馈经常“失败”,即使失败对于发布此类测试的版本来说并不是必需的。

Lots of organizations run those tests as part of the integration automated pipeline that runs for each release or pull request. However, this causes huge delays in feedback, and the feedback is often “failed,” even though the failure is not essential for a release to go out for these types of tests.

我们可以将这些类型的测试分为两大类:

We can divide these types of tests into two main groups:

  • 交付阻塞测试——这些测试为即将发布和部署的变更提供是否进行的决定。单元、端到端、系统和安全测试都属于这一类。他们的反馈是二元的:他们要么通过并宣布更改没有引入任何错误,要么失败并表明代码需要在发布之前修复。

  • Delivery-blocking tests—These are tests that provide a go or no-go for the change that is about to be released and deployed. Unit, E2E, system, and security tests all fall into this category. Their feedback is binary: they either pass and announce that the change didn’t introduce any bugs, or they fail and indicate that the code needs to be fixed before it’s released.

  • 值得了解的测试——这些测试是为了发现和持续监控关键绩效指标 (KPI) 指标而创建的。示例包括代码分析和复杂性扫描、高负载性能测试以及提供非二进制反馈的其他长时间运行的非功能测试。如果这些测试失败,我们可能会在下一个冲刺中添加新的工作项目,但我们仍然可以发布我们的软件。

  • Good-to-know tests—These are tests created for the purpose of discovery and continuous monitoring of key performance indicator (KPI) metrics. Examples include code analysis and complexity scanning, high-load performance testing, and other long-running nonfunctional tests that provide nonbinary feedback. If these tests fail, we might add new work items to our next sprints, but we would still be OK releasing our software.

10.4.1 交付与发现管道

10.4.1 Delivery vs. discovery pipelines

我们不希望我们的众所周知的测试从我们的交付过程中占用宝贵的反馈时间,因此我们还将有两种类型的管道:

We don’t want our good-to-know tests to take valuable feedback time from our delivery process, so we’ll also have two types of pipelines:

  • 交付管道——用于交付阻塞测试。当管道变绿时,我们应该相信我们可以自动将代码发布到生产环境。该管道中的测试应该提供相对快速的反馈。

  • Delivery pipeline—Used for delivery-blocking tests. When the pipeline is green, we should be confident that we can automatically release the code to production. Tests in this pipeline should provide relatively fast feedback.

  • 发现管道——用于众所周知的测试。该管道与交付管道并行运行,但持续运行,并且不将其视为发布标准。由于无需等待其反馈,因此此管道中的测试可能需要很长时间。如果发现错误,它们可能会成为团队下一个冲刺中的新工作项,但发布不会被阻止。

  • Discovery pipeline—Used for good-to-know tests. This pipeline runs in parallel with the delivery pipeline, but continuously, and it’s not taken into account as a release criterion. Since there’s no need to wait for its feedback, tests in this pipeline can take a long time. If errors are found, they might become new work items in the next sprints for the team, but releases are not blocked.

图10.6说明了这两种管道的特点。

Figure 10.6 illustrates the features of these two kinds of pipelines.

10-06



图 10.6 交付与发现管道

Figure 10.6 Delivery vs. discovery pipelines

交付管道的重点是提供一个通过/不通过检查,如果一切看起来都是绿色的,甚至可能部署到生产环境,该检查也会部署我们的代码。发现管道的重点是为团队提供重构目标,例如处理变得太高的代码复杂性。它还可以显示这些重构工作随着时间的推移是否有效。除了运行专门测试或分析代码及其各种 KPI 指标的目的之外,发现管道不会部署任何内容。它以仪表板上的数字结尾。

The point of the delivery pipeline is to provide a go/no-go check that also deploys our code if all seems green, perhaps even to production. The point of the discovery pipeline is to provide refactoring objectives for the team, such as dealing with code complexity that has become too high. It can also show whether those refactoring efforts are effective over time. The discovery pipeline does not deploy anything except for the purpose of running specialized tests or analyzing code and its various KPI metrics. It ends with numbers on a dashboard.

速度是让团队更加投入的一个重要因素,将测试分为发现和交付管道是您的武器库中的另一种技术。

Speed is a big factor in getting teams to be more engaged, and splitting tests into discovery and delivery pipelines is yet another technique to keep in your arsenal.

10.4.2 测试层并行化

10.4.2 Test layer parallelization

由于快速反馈非常重要,因此在许多场景中您可以并且应该采用的常见模式是并行运行不同的测试层以加速管道反馈,如图 10.7 所示。您甚至可以使用动态创建并在测试结束时销毁的并行环境。

Since fast feedback is very important, a common pattern you can and should employ in many scenarios is to run different test layers in parallel to speed up the pipeline feedback, as shown in figure 10.7. You can even use parallel environments that are created dynamically and destroyed at the end of the test.

10-07



图 10.7 为了加快交付速度,您可以并行运行管道,甚至管道中的阶段。

Figure 10.7 To speed up delivery, you can run pipelines, and even stages in pipelines, in parallel.

这种方法受益于动态环境的访问。在环境和自动化并行测试上投入资金几乎总是比投入资金让更多人进行更多手动测试,或者只是让人们等待更长时间才能获得反馈(因为环境正在使用)要有效得多。

This approach benefits greatly from having access to dynamic environments. Throwing money at environments and automated parallel tests is almost always much more effective than throwing money at more people to do more manual tests, or simply having people wait longer to get feedback because the environment is being used right now.

手动测试是不可持续的,因为这种手动工作只会随着时间的推移而增加,并且变得越来越脆弱且容易出错。与此同时,仅仅等待更长时间的管道反馈就会给每个人带来巨大的时间浪费。等待时间乘以等待的人数和每天的构建数量,得出的每月投资可能比动态环境和自动化的投资要大得多。获取一个 Excel 文件并向您的经理展示一个简单的公式来计算预算。

Manual testing is unsustainable because such manual work only increases over time and becomes more and more frail and error prone. At the same time, simply waiting longer for pipeline feedback results in a huge waste of time for everyone. The waiting time, multiplied by the number of people waiting and the number of builds per day, results in a monthly investment that can be much larger than investing in dynamic environments and automation. Grab an Excel file and show your manager a simple formula to get that budget.

您不仅可以并行化管道内的阶段,还可以并行化管道内的各个阶段。您还可以进一步并行运行单独的测试。例如,如果您遇到大量 E2E 测试,您可以将它们分解为并行测试套件。这可以节省反馈循环的大量时间。

You can parallelize not only stages inside a pipeline; you can go further and run individual tests in parallel too. For example, if you’re stuck with a large number of E2E tests, you can break them up into parallel test suites. That shaves a lot of time off your feedback loop.

不要进行夜间构建

Don’t do nightly builds

最好在每次代码提交后运行交付管道,而不是在特定时间运行。对每个代码更改运行测试可以为您提供比简单地累积前一天所有更改的夜间构建更精细、更快速的反馈。但是,如果出于某种原因,您绝对必须及时运行管道,那么至少要连续运行它们,而不是每天运行一次。

It’s best to run your delivery pipeline after every code commit, instead of at a certain time. Running tests with each code change gives you more granular and faster feedback than the crude nightly build that simply accumulates all changes from the previous day. But if, for some reason, you absolutely have to run your pipeline on a timely basis, at least run them continuously instead of once a day.

如果您的交付管道构建需要很长时间,请不要等待神奇的触发器或计划来运行它。想象一下,作为一名开发人员,需要等到明天才能知道您是否损坏了某些东西。如果测试持续进行,你仍然需要等待,但至少只需要几个小时,而不是一整天。这样不是更有生产力吗?

If your delivery pipeline build takes a long time, don’t wait for a magical trigger or schedule to run it. Imagine, as a developer, needing to wait until tomorrow to know if you broke something. With tests running continuously, you would still need to wait, but at least it would only be a couple of hours instead of a full day. Isn’t that more productive?

另外,不要只按需运行构建。当然,如果您在上一个构建完成后立即自动运行构建,那么反馈循环将会更快,前提是自上一个构建以来存在代码更改。

Also, don’t just run the build on demand. The feedback loop will be faster if you run the build automatically as soon as the previous one finishes, assuming there are code changes since the previous build, of course.

概括

Summary

  • 有多个级别的测试:在内存中运行的单元测试、组件测试和集成测试;以及 API、隔离的端到端 (E2E) 以及在进程外运行的系统 E2E 测试。

  • There are multiple levels of tests: unit, component, and integration tests that run in memory; and API, isolated end-to-end (E2E), and system E2E tests that run out of process.

  • 每个测试都可以通过五个标准来评判:复杂性、脆弱性、通过时的置信度、可维护性和执行速度。

  • Each test can be judged by five criteria: complexity, flakiness, confidence when it passes, maintainability, and execution speed.

  • 单元和组件测试在可维护性、执行速度以及缺乏复杂性和脆弱性方面是最好的,但在提供的可信度方面却是最差的。集成和 API 测试是信心与其他指标之间权衡的中间立场。E2E 测试采用与单元测试相反的方法:它们提供最佳的置信度,但代价是可维护性、速度、复杂性和脆弱性。

  • Unit and component tests are best in terms of maintainability, execution speed, and lack of complexity and flakiness, but they’re worst in terms of the confidence they provide. Integration and API tests are the middle ground in the trade-off between confidence and the other metrics. E2E tests take the opposite approach from unit tests: they provide the best confidence but at the expense of maintainability, speed, complexity, and flakiness.

  • 仅端到端反模式是指您的构建仅包含 E2E 测试。每次额外的E2E测试的边际价值很低,而所有测试的维护成本是相同的。如果您只需进行一些涵盖最重要功能的 E2E 测试,您的努力就会获得最大回报。

  • The end-to-end-only antipattern is when your build consists solely of E2E tests. The marginal value of each additional E2E test is low, while the maintenance costs of all tests are the same. You’ll get the most return on your efforts if you have just a few E2E tests covering the most important functionality.

  • 仅低级反模式是指您的构建仅包含单元和组件测试。较低级别的测试无法提供足够的信心来证明您的功能作为一个整体可以正常工作,并且必须用较高级别的测试来补充它们。

  • The low-level-only antipattern is when your build consists solely of unit and component tests. Lower-level tests can’t provide enough confidence that your functionality as a whole works, and they must be supplemented with higher-level tests.

  • 断开的低级和高级测试是一种反模式,因为它强烈表明您的测试是由两组彼此不沟通的人编写的。此类测试经常相互重复并带来高昂的维护成本。

  • Disconnected low-level and high-level tests is an antipattern because it’s a strong sign that your tests are written by two groups of people who don’t communicate with each other. Such tests often duplicate each other and carry high maintenance costs.

  • 测试配方是一个由 5 到 20 行文本组成的简单列表,详细说明了应该以自动化方式测试哪些简单场景以及在什么级别进行测试。测试方案应该让您确信,如果所有概述的测试都通过,则该功能将按预期工作。

  • A test recipe is a simple list of 5 to 20 lines of text, detailing which simple scenarios should be tested in an automated fashion and at what level. A test recipe should give you confidence that, if all outlined tests pass, the feature works as intended.

  • 将构建管道分为交付管道发现管道。交付管道应用于交付阻塞测试,如果失败,则停止交付被测代码。发现管道用于众所周知的测试,并与交付管道并行运行。

  • Split your build pipeline into delivery and discovery pipelines. The delivery pipeline should be used for delivery-blocking tests, which, if they fail, stop delivery of the code under test. The discovery pipeline is used for good-to-know tests and runs in parallel with the delivery pipeline.

  • 您不仅可以并行化管道,还可以并行化这些管道内的阶段,甚至还可以并行化阶段内的测试组。

  • You can parallelize not just pipelines but also stages inside those pipelines, and even groups of tests inside stages too.

11 将单元测试集成到组织中

11 Integrating unit testing into the organization

本章涵盖

This chapter covers

  • 成为变革的推动者
  • Becoming an agent of change
  • 自上而下或自下而上实施变革
  • Implementing change from the top down or from the bottom up
  • 准备回答有关单元测试的棘手问题
  • Preparing to answer the tough questions about unit testing

作为一名顾问,我帮助多家大大小小的公司将持续交付流程和各种工程实践(例如测试驱动开发和单元测试)集成到他们的组织文化中。有时这种做法会失败,但那些成功的公司有几个共同点。在任何类型的组织中,改变人们的习惯更多的是心理上的而不是技术上的。人们不喜欢改变,而改变通常伴随着大量的 FUD(恐惧、不确定性和怀疑)。正如您将在本章中看到的那样,对于大多数人来说,这并不像在公园里散步那样轻松。

As a consultant, I’ve helped several companies, big and small, integrate continuous delivery processes and various engineering practices, such as test-driven development and unit testing, into their organizational culture. Sometimes this has failed, but those companies that succeeded had several things in common. In any type of organization, changing people’s habits is more psychological than technical. People don’t like change, and change is usually accompanied with plenty of FUD (fear, uncertainty, and doubt) to go around. It won’t be a walk in the park for most people, as you’ll see in this chapter.

11.1 成为变革推动者的步骤

11.1 Steps to becoming an agent of change

如果您要成为组织中变革的推动者,您应该首先接受这个角色。人们会将你视为对正在发生的事情负责(有时是负责)的人,无论你是否希望他们这样做,隐藏是没有用的。事实上,隐藏可能会导致事情变得非常糟糕。

If you’re going to be the agent of change in your organization, you should first accept that role. People will view you as the person responsible (and sometimes accountable) for what’s happening, whether or not you want them to, and there’s no use in hiding. In fact, hiding can cause things to go terribly wrong.

当你开始实施或推动变革时,人们会开始提出与他们关心的问题相关的尖锐问题。这会“浪费”多少时间?作为一名 QA 工程师,这对我意味着什么?我们怎么知道它有效?准备好回答。第 11.5 节讨论了最常见问题的答案。您会发现,当您需要做出艰难的决定并回答这些问题时,在开始做出改变之前说服组织内部的其他人会对您有很大帮助。

As you start to implement or push for changes, people will start asking tough questions related to what they care about. How much time will this “waste”? What does this mean for me as a QA engineer? How do we know it works? Be prepared to answer. The answers to the most common questions are discussed in section 11.5. You’ll find that convincing others inside the organization before you start making changes will help you immensely when you need to make tough decisions and answer those questions.

最后,必须有人继续掌舵,确保变革不会因缺乏动力而失败。那是你。有一些方法可以让事物保持活力,正如您将在下一节中看到的那样。

Finally, someone will have to stay at the helm, making sure the changes don’t die for lack of momentum. That’s you. There are ways to keep things alive, as you’ll see in the next sections.

11.1.1 为棘手问题做好准备

11.1.1 Be prepared for the tough questions

做你的研究。阅读本章末尾的问题和答案,并查看相关资源。阅读论坛、邮件列表和博客,并向您的同行咨询。如果你能回答自己的棘手问题,那么你就有很好的机会回答别人的问题。

Do your research. Read the questions and answers at the end of this chapter, and look at the related resources. Read forums, mailing lists, and blogs, and consult with your peers. If you can answer your own tough questions, there’s a good chance you can answer someone else’s.

11.1.2 说服内部人士:拥护者和阻碍者

11.1.2 Convince insiders: Champions and blockers

在组织中,没有什么比逆流而行的决定更让你感到孤独的了。如果您是唯一认为自己正在做的事情是个好主意的人,那么任何人都没有理由努力实施您所倡导的事情。考虑谁可以帮助或损害你的努力:支持者和阻碍者。

Few things make you feel as lonely in an organization as the decision to go against the current. If you’re the only one who thinks what you’re doing is a good idea, there’s little reason for anyone to make an effort to implement what you’re advocating. Consider who can help and hurt your efforts: the champions and blockers.

冠军

Champions

当您开始推动变革时,请确定您认为最有可能为您的追求提供帮助的人。他们将成为你的冠军。他们通常是早期采用者,或者是思想开放的人,愿意尝试您所提倡的事情。他们可能已经半信半疑,但正在寻找开始改变的动力。他们甚至可能自己尝试过但失败了。

As you start pushing for change, identify the people you think are most likely to help in your quest. They’ll be your champions. They’re usually early adopters, or people who are open minded enough to try the things you’re advocating. They may already be half convinced but are looking for an impetus to start the change. They may have even tried it and failed on their own.

在其他人之前接近他们,询问他们对你要做的事情的意见。他们可能会告诉您一些您没有考虑过的事情,包括

Approach them before anyone else and ask for their opinions on what you’re about to do. They may tell you some things that you hadn’t considered, including

  • 可能是不错的入手候选团队

  • Teams that might be good candidates to start with

  • 人们更容易接受此类变化的地方

  • Places where people are more accepting of such changes

  • 在你的探索中要注意什么(和谁)

  • What (and who) to watch out for in your quest

通过与他们接触,您可以帮助确保他们参与该流程。感觉自己是这个过程一部分的人通常会尽力帮助它发挥作用。让他们成为你的拥护者:询问他们是否可以帮助你,并成为人们可以向他们提出问题的人。让他们为此类事件做好准备。

By approaching them, you’re helping to ensure that they’re part of the process. People who feel part of the process usually try to help make it work. Make them your champions: ask them if they can help you and be the ones people can come to with questions. Prepare them for such events.

阻碍者

Blockers

接下来,确定阻碍因素。这些人是组织中最有可能抵制你正在做出的改变的人。例如,经理可能反对添加单元测试,声称这会增加太多的开发时间并增加需要维护的代码量。通过让他们(至少是那些愿意并且有能力的人)在过程中发挥积极作用,使他们成为过程的一部分,而不是过程的抵制者。

Next, identify the blockers. These are the people in the organization who are most likely to resist the changes you’re making. For example, a manager might object to adding unit tests, claiming that they’ll add too much time to the development effort and increase the amount of code that needs to be maintained. Make them part of the process instead of resisters of it by giving them (at least those who are willing and able) an active role in the process.

人们抵制变革的原因各不相同。关于影响力的第 11.4 节涵盖了对一些可能反对意见的回答。有些人会担心工作保障,有些人会对目前的情况感到太舒服。接近潜在的阻碍者并详细说明他们可以做得更好的所有事情通常没有建设性,正如我经过艰难的方式发现的那样。人们不喜欢被告知他们的孩子很丑。

The reasons why people might resist changes vary. Answers to some of the possible objections are covered in section 11.4 on influence forces. Some will be worried about job security, and some will just feel too comfortable with the way things currently are. Approaching potential blockers and detailing all the things they could have done better is often not constructive, as I’ve found out the hard way. People don’t like to be told that their baby is ugly.

相反,请阻止者在此过程中帮助您,例如,负责定义单元测试的编码标准,或者每隔一天与同事一起进行代码和测试审查。或者让他们成为选择课程材料或外部顾问的团队的一部分。您将赋予他们新的责任,帮助他们在组织中感到被依赖和相关。他们需要成为变革的一部分,否则几乎肯定会破坏变革。

Instead, ask blockers to help you in the process by being in charge of defining coding standards for unit tests, for example, or by doing code and test reviews with peers every other day. Or make them part of the team that chooses the course materials or outside consultants. You’ll give them a new responsibility that will help them feel relied on and relevant in the organization. They need to be part of the change or they’ll almost certainly undermine it.

11.1.3 确定可能的起点

11.1.3 Identify possible starting points

确定您可以在组织中的哪些位置开始实施变革。大多数成功的实施都采取稳定的路线。从一个小团队的试点项目开始,看看会发生什么。如果一切顺利,则继续进行其他团队和其他项目。

Identify where in the organization you can start implementing changes. Most successful implementations take a steady route. Start with a pilot project in a small team, and see what happens. If all goes well, move on to other teams and other projects.

以下是一些可以为您提供帮助的提示:

Here are some tips that will help you along the way:

  • 选择较小的团队。

  • Choose smaller teams.

  • 创建子团队。

  • Create subteams.

  • 考虑项目的可行性。

  • Consider project feasibility.

  • 使用代码和测试评论作为教学工具。

  • Use code and test reviews as teaching tools.

这些技巧可以帮助您在充满敌意的环境中走得更远。

These tips can take you a long way in a mostly hostile environment.

选择较小的团队

Choose smaller teams

确定可以开始的团队通常很容易。您通常会希望有一个小团队致力于低风险的低调项目。如果风险很小,就更容易说服人们尝试您提出的更改。

Identifying possible teams to start with is usually easy. You’ll generally want a small team working on a low-profile project with low risks. If the risk is minimal, it’s easier to convince people to try your proposed changes.

需要注意的是,团队需要有愿意改变工作方式和学习新技能的成员。讽刺的是,团队中经验较少的人通常最有可能乐于改变,而经验丰富的人往往更坚持自己的做事方式。如果您能找到一个团队,其领导者经验丰富,愿意接受变革,但也包括经验不足的开发人员,那么该团队很可能不会遇到什么阻力。到团队中询问他们对举办试点项目的意见。他们会告诉您这是否是正确的起点。

One caveat is that the team needs to have members who are open to changing the way they work and to learning new skills. Ironically, the people with less experience on a team are usually most likely to be open to change, and people with more experience tend to be more entrenched in their way of doing things. If you can find a team with an experienced leader who’s open to change, but that also includes less-experienced developers, it’s likely that team will offer little resistance. Go to the team and ask their opinion on holding a pilot project. They’ll tell you if this is (or is not) the right place to start.

创建子团队

Create subteams

试点测试的另一个可能的候选者是在现有团队内组建一个子团队。几乎每个团队都会有一个需要维护的“黑洞”组件,虽然它做了很多正确的事情,但它也存在很多错误。为这样的组件添加功能是一项艰巨的任务,这种痛苦会驱使人们尝试试点项目。

Another possible candidate for a pilot test is to form a subteam within an existing team. Almost every team will have a “black hole” component that needs to be maintained, and while it does many things right, it also has many bugs. Adding features for such a component is a tough task, and this kind of pain can drive people to experiment with a pilot project.

考虑项目可行性

Consider project feasibility

对于试点项目,请确保您不会贪多嚼不烂。运行更困难的项目需要更多的经验,因此您可能希望至少有两个选项 - 一个复杂的项目和一个更简单的项目 - 以便您可以在它们之间进行选择。

For a pilot project, make sure you’re not biting off more than you can chew. It takes more experience to run more difficult projects, so you might want to have at least two options—a complicated project and an easier project—so that you can choose between them.

使用代码和测试评论作为教学工具

Use code and test reviews as teaching tools

如果您是一个小团队(最多八人)的技术主管,最好的教学方法之一是进行代码审查,其中也包括测试审查。这个想法是,当您审查其他人的代码和测试时,您可以教他们您在测试中寻找什么以及您编写测试或接近 TDD 的思维方式。以下是一些提示:

If you’re the technical lead on a small team (up to eight people), one of the best ways of teaching is instituting code reviews that also include test reviews. The idea is that as you review other people’s code and tests, you teach them what you look for in the tests and your way of thinking about writing tests or approaching TDD. Here are some tips:

  • 亲自进行评审,而不是通过远程软件。个人联系让你们之间以非语言方式传递更多信息,因此学习效果更好、更快。

  • Do the reviews in person, not through remote software. The personal connection lets much more information pass between you in nonverbal ways, so learning happens better and faster.

  • 在最初的几周内,检查签入的每一行代码。这将帮助您避免“我们认为该代码不需要检查”的问题。

  • In the first couple of weeks, review every line of code that gets checked in. This will help you avoid the “we didn’t think this code needs reviewing” problem.

  • 在您的代码审查中添加第三人——他将坐在一边并了解您如何审查代码。这将使他们以后能够自己进行代码审查并指导其他人,这样您就不会成为团队的瓶颈,因为您是唯一有能力进行审查的人。这个想法是培养其他人进行代码审查并承担更多责任的能力。

  • Add a third person to your code reviews—one who will sit on the side and learn how you review the code. This will allow them to later do code reviews themselves and teach others, so that you won’t become a bottleneck for the team as the only person capable of doing reviews. The idea is to develop others’ ability to do code reviews and accept more responsibility.

如果您想了解有关此技术的更多信息,我在为技术领导者撰写的博客中写了相关内容:“良好的代码审查应该是什么样子?” 在https://5whys.com/blog/what-should-a-good-code-review-look-and-feel-like.xhtml

If you want to learn more about this technique, I wrote about it in my blog for technical leaders: “What Should a Good Code Review Look and Feel Like?” at https://5whys.com/blog/what-should-a-good-code-review-look-and-feel-like.xhtml.

11.2 成功之道

11.2 Ways to succeed

组织或团队可以通过两种主要方式开始改变流程:自下而上或自上而下(有时两者兼而有之)。正如您将看到的,这两种方法非常不同,并且任何一种方法都可能适合您的团队或公司。没有一种方法是正确的。

There are two main ways an organization or team can start changing a process: from the bottom-up or the top-down (and sometimes both). The two ways are very different, as you’ll see, and either could be the right approach for your team or company. There’s no one right way.

当你继续前进时,你需要学习如何说服管理层,你的努力也应该是他们的努力,或者何时引入外部人员提供帮助是明智的。让进展可见很重要,制定可衡量的明确目标也很重要。识别和避免障碍也应该是你的首要任务。可以进行的战斗有很多,你需要选择正确的战斗。

As you proceed, you’ll need to learn how to convince management that your efforts should also be their efforts, or when it would be wise to bring in someone from outside to help. Making progress visible is important, as is setting clear goals that can be measured. Identifying and avoiding obstacles should also be high on your list. There are many battles that can be fought, and you need to choose the right ones.

11.2.1 Guerrilla 实现(自下而上)

11.2.1 Guerrilla implementation (bottom-up)

游击式实施就是从一个团队开始,取得成果,然后才让其他人相信这些做法是值得的。通常,游击式实施的驱动力是一个厌倦了按规定方式做事的团队。他们开始采取不同的做法;他们自己学习并做出改变。当团队显示结果时,组织中的其他人可能会决定开始在自己的团队中实施类似的变革。

Guerrilla-style implementation is all about starting out with a team, getting results, and only then convincing other people that the practices are worthwhile. Usually the driver for guerrilla implementation is a team who’s tired of doing things the prescribed way. They set out to do things differently; they study on their own and make changes happen. When the team shows results, other people in the organization may decide to start implementing similar changes in their own teams.

在某些情况下,游击式实施是首先由开发人员采用,然后由管理层采用的过程。有时,这是一个首先由开发人员倡导,然后由管理层倡导的过程。不同之处在于,你可以秘密地完成第一个任务,而不需要更高的权力知道。后者是与管理层一起完成的。由您决定哪种方法更有效。有时改变事情的唯一方法是秘密行动。如果可以的话,请避免这种情况,但如果没有其他方法,并且您确定需要进行更改,则可以这样做。

In some cases, guerrilla-style implementation is a process adopted first by developers and then by management. At other times, it’s a process advocated for first by developers and then by management. The difference is that you can accomplish the first covertly, without the higher powers knowing about it. The latter is done in conjunction with management. It’s up to you to figure out which approach will work better. Sometimes the only way to change things is by covert operations. Avoid this if you can, but if there’s no other way, and you’re sure the change is needed, you can just do it.

不要将此视为做出限制职业生涯的建议。开发人员总是在未经许可的情况下做事:调试代码、阅读电子邮件、编写代码注释、创建流程图等等。这些都是开发人员日常工作中要做的任务。单元测试也是如此。大多数开发人员已经编写了某种类型的测试(自动化或非自动化)。这个想法是将花在测试上的时间重新定向到能够提供长期效益的事情上。

Don’t take this as a recommendation to make a career-limiting move. Developers do things without permission all the time: debugging code, reading email, writing code comments, creating flow diagrams, and so on. These are all tasks that developers do as a regular part of the job. The same goes for unit testing. Most developers already write tests of some sort (automated or not). The idea is to redirect the time spent on tests into something that will provide benefits in the long term.

11.2.2 令人信服的管理(自上而下)

11.2.2 Convincing management (top-down)

自上而下的行动通常以两种方式之一开始。经理或开发人员将启动该流程,并开始组织的其他部分逐步朝这个方向前进。或者,中层经理可能会观看演示、阅读一本书(例如这本书)或与同事讨论对他们的工作方式进行特定改变的好处。这样的经理通常会通过向其他团队的人员进行演示来启动该流程,甚至利用他们的权力来实现变革。

The top-down move usually starts in one of two ways. A manager or a developer will initiate the process and start the rest of the organization moving in that direction, piece by piece. Or a mid-level manager may see a presentation, read a book (such as this one), or talk to a colleague about the benefits of specific changes to the way they work. Such a manager will usually initiate the process by giving a presentation to people in other teams or even using their authority to make the change happen.

11.2.3 开门实验

11.2.3 Experiments as door openers

这是在大型组织中开始进行单元测试的一种强大方法(它也适合其他类型的转型或新技能)。宣布一项将持续两到三个月的实验。它将仅适用于一个预先选择的团队,并且仅与实际应用程序中的一两个组件相关。确保它不会太冒险。如果失败,公司不会破产或失去主要客户。它也不应该是无用的:实验必须提供真正的价值,而不仅仅是作为游乐场。它必须是您最终将推入代码库并最终在生产中使用的东西;它不应该是一段写完就忘记的代码。

Here’s a powerful way to get started with unit testing in a large organization (it could also fit other types of transformation or new skills). Declare an experiment that will last two to three months. It will apply to only one pre-picked team and relate to only one or two components in a real application. Make sure it’s not too risky. If it fails, the company won’t go under or lose a major client. It also shouldn’t be useless: the experiment must provide real value and not just serve as a playground. It has to be something you’ll end up pushing into your codebase and use in production eventually; it shouldn’t be a write-and-forget piece of code.

“实验”这个词传达了这种改变是暂时的,如果不起作用,团队可以回到之前的状态。此外,这项工作是有时间限制的,因此我们知道实验何时完成。

The word “experiment” conveys that the change is temporary, and if it doesn’t work out, the team can go back to the way they were before. Also, the effort is time-boxed, so we know when the experiment is finished.

这种方法可以帮助人们对重大变化感到更加放心,因为它减少了组织的风险、受影响的人数(以及反对的人数)以及因担心“永远改变事​​情”而提出的反对意见的数量”。

Such an approach helps people feel more at ease with big changes, because it reduces the risk to the organization, the number of people affected (and thus the number of people objecting), and the number of objections relating to fear of changing things “forever.”

这里有另一个提示:当面对一个实验的多种选择时,或者如果你反对推动另一种工作方式,请问,“我们想先尝试哪个想法?”

Here’s another hint: when faced with multiple options for an experiment, or if you get objections pushing for another way of working, ask, “Which idea do we want to experiment with first?”

走走走走

Walk the walk

请做好准备,您的想法可能不会从所有实验选项中被选中。当紧要关头,无论你喜欢与否,你都必须根据领导层的共识决定进行实验。

Be prepared that your idea might not be selected from among all the options for an experiment. When push comes to shove, you have to hold experiments based on what the consensus of leadership decides, whether you like it or not.

参与其他人的实验的好处在于,就像你的实验一样,它们是有时间限制的且是暂时的!最好的结果可能是另一种方法可以解决您试图解决的问题,并且您可能希望继续别人的实验。然而,如果你讨厌这个实验,只要记住它是暂时的,你可以推动下一个实验。

The nice thing about going with other people’s experiments is that, like with yours, they are time-boxed and temporary! The best outcome might be that another approach fixes what you were trying to fix, and you might want to keep someone else’s experiment going. However, if you hate the experiment, just remember that it’s temporary, and you can push for the next experiment.

指标和实验

Metrics and experiments

请务必在实验前后记录一组基准指标。这些指标应该与您尝试更改的内容相关,例如消除构建的等待时间、减少产品上市的准备时间或减少生产中发现的错误数量。

Be sure to record a baseline set of metrics before and after the experiment. These metrics should be related to things you’re trying to change, such as eliminating waiting times for a build, reducing the lead time for a product to go out the door, or reducing the number of bugs found in production.

要更深入地了解您可能使用的各种指标,请查看我的演讲“谎言、该死的谎言和指标”,您可以在我的博客中找到该演讲:https: //pipelinedriven.org/article/video-lies-damned -谎言和指标

To dive deeper into the various metrics you might use, take a look at my talk “Lies, Damned Lies, and Metrics,” which you can find in my blog at https://pipelinedriven.org/article/video-lies-damned-lies-and-metrics.

11.2.4 获得外部冠军

11.2.4 Get an outside champion

我强烈建议聘请外部人员来帮助进行变革。外部顾问来帮助单元测试和相关事务比在公司工作的人有优势:

I highly recommend getting an outside person to help with the change. An outside consultant coming in to help with unit testing and related matters has advantages over someone who works in the company:

  • 言论自由——顾问可以说出公司内部人员可能不愿意从公司工作人员那里听到的话(“代码完整性很差”,“你的测试不可读”等等)。

  • Freedom to speak—A consultant can say things that people inside the company may not be willing to hear from someone who works there (“The code integrity is bad,” “Your tests are unreadable,” and so on).

  • 经验——顾问将有更多的经验来处理来自内部的阻力,为棘手的问题提供好的答案,并知道应该按下哪些按钮来让事情顺利进行。

  • Experience—A consultant will have more experience dealing with resistance from the inside, coming up with good answers to tough questions, and knowing which buttons to push to get things going.

  • 奉献时间——对于顾问来说,这是他们的工作。与公司中其他有比推动变革(例如编写软件)更重要的事情要做的员工不同,顾问全职从事这项工作并致力于此目的。

  • Dedicated time—For a consultant, this is their job. Unlike other employees in the company who have better things to do than push for change (like writing software), the consultant does this full time and is dedicated to this purpose.

我经常看到变革失败是因为过度劳累的冠军没有时间专注于这个过程。

I’ve often seen a change break down because an overworked champion doesn’t have the time to dedicate to the process.

11.2.5 让进展可见

11.2.5 Make progress visible

保持变更的进度和状态可见非常重要。在走廊的墙壁上或人们聚集的与食品相关的区域悬挂白板或海报。显示的数据应该与您想要实现的目标相关。例如:

It’s important to keep the progress and status of the change visible. Hang whiteboards or posters on walls in corridors or in the food-related areas where people congregate. The data displayed should be related to the goals you’re trying to achieve. For example:

  • 显示上次夜间构建中通过或失败的测试数量。

  • Show the number of passing or failing tests in the last nightly build.

  • 保留一个图表,显示哪些团队已经在运行自动化构建流程。

  • Keep a chart showing which teams are already running an automated build process.

  • 如果您设定了目标,请张贴迭代进度的 Scrum 燃尽图或测试代码覆盖率报告(如图 11.1 所示)。(您可以在www.controlchaos.com上了解有关 Scrum 的更多信息。)

  • Put up a Scrum burndown chart of iteration progress or a test-code-coverage report (as shown in figure 11.1) if that’s what you have your goals set to. (You can learn more about Scrum at www.controlchaos.com.)

11-01



图 11.1 TeamCity 中使用 NCover 的测试代码覆盖率报告示例

Figure 11.1 An example of a test-code-coverage report in TeamCity with NCover

  • 留下您自己和所有冠军的联系方式,以便有人可以回答出现的任何问题。

  • Put up contact details for yourself and all the champions, so someone can answer any questions that arise.

  • 设置一个大屏幕显示器,始终以大粗体图形显示构建的状态、当前正在运行的内容以及失败的内容。将其放在所有开发人员都可以看到的显眼位置 - 例如,在人流量大的走廊中,或者在团队房间主墙的顶部。

  • Set up a big-screen display that’s always showing, in big bold graphics, the status of the builds, what’s currently running, and what’s failing. Put that in a visible place where all developers can see—in a well-trafficked corridor, for example, or at the top of the team room’s main wall.

您使用这些图表的目的是与两个群体建立联系:

Your aim in using these charts is to connect with two groups:

  • 经历变革的群体——随着图表(向所有人开放)更新,该群体中的人们将获得更大的成就感和自豪感,并且他们会感到更有动力完成该过程,因为它对其他人是可见的。他们还能够跟踪自己与其他群体相比的表现。他们可能会更加努力,因为他们知道另一个小组更快地实施了特定的做法。

  • The group undergoing the change—People in this group will gain a greater feeling of accomplishment and pride as the charts (which are open to everyone) are updated, and they’ll feel more compelled to complete the process because it’s visible to others. They’ll also be able to keep track of how they’re doing compared to other groups. They may push harder, knowing that another group implemented specific practices more quickly.

  • 组织中那些不参与该流程的人——您要提高这些人的兴趣和好奇心,引发对话和讨论,并创建一个他们可以选择加入的潮流。

  • Those in the organization who aren’t part of the process—You’re raising interest and curiosity among these people, triggering conversations and buzz, and creating a current that they can join if they choose.

11.2.6 瞄准具体目标、指标和 KPI

11.2.6 Aim for specific goals, metrics, and KPIs

如果没有目标,变革将难以衡量并与他人沟通。这将是一个模糊的“东西”,一旦出现麻烦迹象就很容易被关闭。

Without goals, the change will be hard to measure and to communicate to others. It will be a vague “something” that can easily be shut down at the first sign of trouble.

滞后指标

Lagging indicators

在组织层面,单元测试通常是更大目标的一部分,通常与持续交付相关。如果您属于这种情况,我强烈建议您使用四个常见的 DevOps 指标:

At the organizational level, unit tests are generally part of a bigger set of goals, usually related to continuous delivery. If that’s the case for you, I highly recommend using the four common DevOps metrics:

  • 部署频率——组织成功发布到生产环境的频率。

  • Deployment frequency—How often an organization successfully releases to production.

  • 变更前置时间——功能请求进入生产所需的时间。请注意,许多地方错误地将其发布为提交到生产所需的时间,从组织的角度来看,这只是功能经历的旅程的一部分。如果您从提交时间进行测量,则更接近于测量功能从提交到特定点的“周期时间”。提前期由多个周期时间组成。

  • Lead time for changes—The time it takes a feature request to get into production. Note that many places incorrectly publish this as the amount of time it takes a commit to get into production, which is only a part of the journey that a feature goes through, from an organizational standpoint. If you’re measuring from commit time, you’re closer to measuring the “cycle time” of a feature from commit up to a specific point. Lead time is made up of multiple cycle times.

  • 逃逸错误/变更失败率——每个单元(通常是发布、部署或时间)在生产中发现的失败数量。您还可以使用导致生产失败的部署百分比。

  • Escaped bugs/change failure rate—The number of failures found in production per some unit, usually release, deployment, or time. You can also use the percentage of deployments causing a failure in production.

  • 恢复服务的时间——组织从生产故障中恢复需要多长时间。

  • Time to restore service—How long it takes an organization to recover from a failure in production.

这四个指标就是我们所说的滞后指标,它们很难伪造(尽管它们在大多数地方很容易衡量)。它们非常有助于确保我们不会在实验结果上欺骗自己。

These four are what we’d call lagging indicators, and they’re very hard to fake (although they’re pretty easy to measure in most places). They are great in making sure we do not lie to ourselves about the results of experiments.

领先指标

Leading indicators

通常,我们希望获得更快的反馈,以确保我们走在正确的道路上。这就是领先指标的用武之地。领先指标是我们日常可以控制的东西——代码覆盖率、测试数量、构建运行时间等等。它们更容易伪造,但与滞后指标相结合,它们通常可以为我们提供早期迹象,表明我们可能正在走正确的道路。

Often we’d like faster feedback to ensure that we’re going the right way. That’s where leading indicators come in. Leading indicators are things we can control on a day-to-day basis—code coverage, number of tests, build run time, and more. They are easier to fake, but combined with lagging indicators, they can often provide us with early signs that we might be going the right way.

图 11.2 显示了您可以在组织中使用的滞后指标和领先指标的示例结构和想法。您可以在https://pipelinedriven.org/article/a-metrics-framework-for-continuous-delivery找到带有颜色的高分辨率图像。

Figure 11.2 shows a sample structure and ideas for lagging and leading indicators you can use in your organization. You can find a high-resolution image with color at https://pipelinedriven.org/article/a-metrics-framework-for-continuous-delivery.

11-02



图 11.2 用于持续交付的度量框架示例

Figure 11.2 An example of a metrics framework for use in continuous delivery

指标类别和组

Indicator categories and groups

我通常将领先指标分为两组:

I usually break up leading indicators into two groups:

  • 团队级别——单个团队可以控制的指标

  • Team level—Metrics that an individual team can control

  • 工程管理级别——需要跨团队协作或跨多个团队聚合指标的指标

  • Engineering management level—Metrics that require cross-team collaboration or aggregate metrics across multiple teams

我还喜欢根据它们将用于解决的问题对它们进行分类:

I also like to categorize them based on what they will be used to solve:

  • 进度——用于解决计划的可见性和决策制定

  • Progress—Used to solve visibility and decision making on the plan

  • 瓶颈和反馈——顾名思义

  • Bottlenecks and feedback—As the name implies

  • 质量——生产中逃逸的错误

  • Quality—Escaped bugs in production

  • 技能——跟踪我们正在慢慢消除团队内部或团队之间的知识障碍

  • Skills—Track that we are slowly removing knowledge barriers inside teams or across teams

  • 学习——表现得就像我们是一个学习型组织

  • Learning—Acting like we’re a learning organization

定性指标

Qualitative metrics

这些指标大多是定量的(即,它们是可以测量的数字),但也有一些是定性的,即您询问人们对某事的感受或想法。我用的是

The metrics are mostly quantitative (i.e., they are numbers that can be measured), but a few are qualitative, in that you ask people how they feel or think about something. The ones I use are

  • 您对测试能够并且将会发现代码中出现的错误(从 1 到 5)有多大信心?取团队成员或多个团队的响应的平均值。

  • How confident you are that the tests can and will find bugs in the code if they arise (from 1 to 5)? Take the average of the responses from the team members or across multiple teams.

  • 代码是否执行了它应该执行的操作(从 1 到 5)?

  • Does the code do what it is supposed to do (from 1 to 5)?

您可以在每次回顾会议上提出这些调查,需要五分钟的时间来回答。

These are surveys you can ask at each retrospective meeting, and they take five minutes to answer.

趋势线是你的朋友

Trend lines are your friend

对于所有领先和滞后指标,您希望看到趋势线,而不仅仅是数字快照。随着时间的推移,线条可以让你看到自己的状态是好转还是变差。

For all leading and lagging indicators, you want to see trend lines, not just snapshots of numbers. Lines over time is how you see if you’re getting better or worse.

不要陷入拥有一个带有大量数字的漂亮仪表板的陷阱。没有背景的数字没有好坏之分。趋势线告诉您本周您是否比上周更好。

Don’t fall into the trap of having a nice dashboard with large numbers on it. Numbers without context are not good or bad. Trend lines tell you if you’re better this week than you were last week.

11.2.7 意识到会有障碍

11.2.7 Realize that there will be hurdles

总是有障碍。大多数将来自组织结构内部,有些将来自技术领域。技术问题更容易解决,因为这是找到正确解决方案的问题。组织方面的人员需要关怀和关注以及心理方法。

There are always hurdles. Most will come from within the organizational structure, and some will be technical. The technical ones are easier to fix, because it’s a matter of finding the right solution. The organizational ones need care and attention and a psychological approach.

当迭代出现问题、测试比预期慢等等时,不要屈服于暂时失败的感觉,这一点很重要。有时很难开始,您需要坚持至少几个月才能开始适应新流程并解决所有问题。让管理层承诺即使事情没有按计划进行,也会持续至少三个月。提前获得他们的同意很重要。您不想在充满压力的第一个月中四处奔走试图说服人们。

It’s important not to surrender to a feeling of temporary failure when an iteration goes bad, tests go slower than expected, and so on. It’s sometimes hard to get going, and you’ll need to persist for at least a couple of months to start feeling comfortable with the new process and to iron out all the kinks. Have management commit to continuing for at least three months even if things don’t go as planned. It’s important to get their agreement up front. You don’t want to be running around trying to convince people in the middle of a stressful first month.

另外,请吸收 Tim Ottinger 在 Twitter (@Tottinge) 上分享的这个简短的认识:“如果您的测试没有捕获所有缺陷,它们仍然可以更轻松地修复未捕获的缺陷。这是一个深刻的真理。”

Also, absorb this short realization, shared by Tim Ottinger on Twitter (@Tottinge): “If your tests don’t catch all defects, they still make it easier to fix the defects they didn’t catch. It is a profound truth.”

现在我们已经研究了确保事情顺利进行的方法,让我们看看一些可能导致失败的事情。

Now that we’ve looked at ways of ensuring things go right, let’s look at some things that can lead to failure.

11.3 失败的原因

11.3 Ways to fail

在本书的前言中,我谈到了我参与的一个失败的项目,部分原因是单元测试没有正确实施。这是项目失败的一种方式。我将在这里讨论其他几个问题,以及导致我的项目成本高昂的一个问题,以及针对这些问题可以采取的一些措施。

In the preface to this book, I talked about one project I was involved with that failed, partly because unit testing wasn’t implemented correctly. That’s one way a project can fail. I’ll discuss several others here, along with one that cost me that project, and some things that can be done about them.

11.3.1 缺乏驱动力

11.3.1 Lack of a driving force

在我见过变革失败的地方,缺乏驱动力是最有力的因素。成为变革的持续驱动力是有代价的。你需要从正常工作中抽出时间来教导他人、帮助他们以及为变革而发动内部政治战争。你需要愿意为这些任务付出时间,否则改变不会发生。如第 11.2.4 节所述,引入外部人员将帮助您寻求一致的驱动力。

In the places where I’ve seen change fail, the lack of a driving force was the most powerful factor in play. Being a consistent driving force of change has its price. It will take time away from your normal job to teach others, help them, and wage internal political wars for change. You need to be willing to surrender time for these tasks, or the change won’t happen. Bringing in an outside person, as mentioned in section 11.2.4, will help you in your quest for a consistent driving force.

11.3.2 缺乏政治支持

11.3.2 Lack of political support

如果你的老板明确告诉你不要做出改变,除了试图说服管理层看到你所看到的之外,你无能为力。但有时缺乏支持的情况比这要微妙得多,关键是要意识到你面临着反对。

If your boss explicitly tells you not to make the change, there isn’t a whole lot you can do, besides trying to convince management to see what you see. But sometimes the lack of support is much more subtle than that, and the trick is to realize that you’re facing opposition.

例如,您可能会被告知:“当然,继续实施这些测试。我们将增加您 10% 的时间来做这件事。” 对于开始单元测试工作来说,任何低于 30% 的值都是不现实的。这是管理者试图阻止趋势的一种方式——阻止它的存在。

For example, you may be told, “Sure, go ahead and implement those tests. We’re adding 10% to your time to do this.” Anything below 30% isn’t realistic for beginning a unit testing effort. This is one way a manager may try to stop a trend—by choking it out of existence.

你需要认识到你正面临着反对,但一旦你知道要寻找什么,就很容易识别。当你告诉他们他们的局限性不现实时,你会被告知,“所以不要这样做。”

You need to recognize that you’re facing opposition, but once you know what to look for, it’s easy to identify. When you tell them that their limitations aren’t realistic, you’ll be told, “So don’t do it.”

11.3.3 特别实施和第一印象

11.3.3 Ad hoc implementations and first impressions

如果您计划在事先不知道如何编写良好的单元测试的情况下实施单元测试,请帮自己一个大忙:让有经验并遵循良好实践的人参与(例如本书中概述的那些)。

If you’re planning to implement unit testing without prior knowledge of how to write good unit tests, do yourself one big favor: involve someone who has experience and follow good practices (such as those outlined in this book).

我见过开发人员在没有正确了解该做什么或从哪里开始的情况下就跳入深水,这不是一个好地方。不仅需要花费大量的时间来学习如何做出适合您的情况的更改,而且您还会因为一开始的实施不佳而失去很多可信度。这可能会导致试点项目被关闭。

I’ve seen developers jump into the deep water without a proper understanding of what to do or where to start, and that’s not a good place to be. Not only will it take a huge amount of time to learn how to make changes that are acceptable for your situation, but you’ll also lose a lot of credibility along the way for starting out with a bad implementation. This can lead to the pilot project being shut down.

如果你读过这本书的序言,你就会知道这发生在我身上。你只有几个月的时间来加快进度并让上级相信你正在通过实验取得成果。充分利用这段时间,并尽可能消除任何风险。如果您不知道如何编写好的测试,请阅读书籍或咨询顾问。如果您不知道如何使代码可测试,请执行相同的操作。不要浪费时间重新发明测试方法。

If you read this book’s preface, you’ll know that this happened to me. You have only a couple of months to get things up to speed and convince the higher-ups that you’re achieving results with experiments. Make that time count, and remove any risks that you can. If you don’t know how to write good tests, read a book or get a consultant. If you don’t know how to make your code testable, do the same. Don’t waste time reinventing testing methods.

11.3.4 缺乏团队支持

11.3.4 Lack of team support

如果您的团队不支持您的努力,那么几乎不可能成功,因为您将很难将新流程上的额外工作与常规工作结合起来。您应该努力让您的团队成为新流程的一部分,或者至少不干扰它。

If your team doesn’t support your efforts, it will be nearly impossible to succeed, because you’ll have a hard time consolidating your extra work on the new process with your regular work. You should strive to have your team be part of the new process or at least not interfere with it.

与您的团队成员讨论这些变化。有时,获得他们的一一支持是一个很好的开始,但与他们作为一个团队讨论你的努力并回答他们的难题也很有价值。无论您做什么,都不要将团队的支持视为理所当然。确保你知道自己要做什么;这些是您每天必须与之合作的人。

Talk to your team members about the changes. Getting their support one by one is sometimes a good way to start, but talking to them as a group about your efforts—and answering their hard questions—can also prove valuable. Whatever you do, don’t take the team’s support for granted. Make sure you know what you’re getting into; these are the people you have to work with on a daily basis.

11.4 影响因素

11.4 Influence factors

我在《弹性领导力》(Manning,2016 年)一书中用一个完整的章节撰写并介绍了影响行为。如果您觉得这个主题很有趣,我建议您选择该主题,或者在5whys.com上阅读更多相关内容。

I’ve written and covered influencing behaviors as a full chapter in my book Elastic Leadership (Manning, 2016). If you find this topic interesting, I recommend picking that one up, or reading more about it at 5whys.com.

我发现比单元测试更令人着迷的事情之一是人以及他们的行为方式的原因。尝试让某人开始做某事(例如 TDD)可能会非常令人沮丧,并且无论您尽最大努力,他们就是不会这样做。你可能已经尝试过与他们推理,但你发现他们对你的闲聊没有任何反应。

One of the things I find even more fascinating than unit tests is people and why they behave the way they do. It can be very frustrating to try to get someone to start doing something (like TDD, for example), and regardless of your best efforts, they just won’t do it. You may have already tried reasoning with them, but you see they don’t do anything in response to your little talk.

在 Kerry Patterson、Joseph Grenny、David Maxfield、Ron McMillan 和 Al Switzler 所著的《影响者:改变一切的力量》 (McGraw-Hill,2007 年)一书中,您会发现以下口头禅(释义):

In the book Influencer: The Power to Change Anything (McGraw-Hill, 2007) by Kerry Patterson, Joseph Grenny, David Maxfield, Ron McMillan, and Al Switzler, you’ll find the following mantra (paraphrased):

对于你看到的每一种行为,世界都是为该行为的发生而完美设计的。这意味着除了人想要做某事或能够做某事之外,还有其他因素影响他们的行为。然而我们很少会超越这两个因素。

For every behavior that you see, the world is perfectly designed for that behavior to happen. That means that there are other factors besides the person wanting to do something or being able to do it that influence their behavior. Yet we rarely look beyond those two factors.

这本书向我们展示了六个影响因素:

The book exposes us to six influence factors:

  • 个人能力——该人是否具备执行所需任务所需的所有技能或知识?

  • Personal ability—Does the person have all the skills or knowledge to perform what is required?

  • 个人动机——人们是否从正确的行为中获得满足或不喜欢错误的行为?当他们最困难的时候,他们是否有自制力去采取这种行为?

  • Personal motivation—Does the person take satisfaction from the right behavior or dislike the wrong behavior? Do they have the self-control to engage in the behavior when it’s hardest to do so?

  • 社交能力——您或其他人是否提供该人所需的帮助、信息和资源,特别是在关键时刻?

  • Social ability—Do you or others provide the help, information, and resources required by that person, particularly at critical times?

  • 社会动机——周围的人是否积极鼓励正确的行为并阻止错误的行为?您或其他人是否以有效的方式树立了正确的行为榜样?

  • Social motivation—Are the people around them actively encouraging the right behavior and discouraging the wrong behavior? Are you or others modeling the right behavior in an effective way?

  • 结构(环境)能力——环境(建筑、预算等)是否有某些方面使行为变得方便、容易和安全?是否有足够的提示和提醒来保持方向?

  • Structural (environmental) ability—Are there aspects in the environment (building, budget, and so on) that make the behavior convenient, easy, and safe? Are there enough cues and reminders to stay on course?

  • 结构性动机——当你或其他人的行为正确或错误时,是否有明确且有意义的奖励(例如工资、奖金或激励)?短期奖励是否与您想要强化或想要避免的长期结果和行为相匹配?

  • Structural motivation—Are there clear and meaningful rewards (such as pay, bonuses, or incentives) when you or others behave the right or wrong way? Do short-term rewards match the desired long-term results and behaviors you want to reinforce or want to avoid?

请将此视为一个简短的清单,用于开始了解为什么事情不按您的方式进行。然后考虑另一个重要事实:游戏中可能有多个因素。为了改变行为,你应该改变所有起作用的因素。如果您只更改其中一项,则行为不会改变。

Consider this a short checklist for starting to understand why things aren’t going your way. Then consider another important fact: there might be more than one factor in play. For the behavior to change, you should change all the factors in play. If you change just one, the behavior won’t change.

表 11.1 是关于不执行 TDD 的人的虚构清单的示例。(请记住,每个组织中的每个人都会有所不同。)

Table 11.1 is an example of an imaginary checklist about someone not performing TDD. (Keep in mind that this will differ for each person in each organization.)

表11.1 影响因素清单

Table 11.1 Influence factors checklist

影响因素

Influence factor

要问的问题

Question to ask

示例答案

Example answer

个人能力

Personal ability

此人是否具备执行所需任务所需的所有技能或知识?

Does the person have all the skills or knowledge to perform what is required?

是的。他们与 Roy Osherove 一起参加了为期三天的 TDD 课程。

Yes. They went through a three-day TDD course with Roy Osherove.

个人动机

Personal motivation

这个人会对正确的行为感到满意还是不喜欢错误的行为?当他们最困难的时候,他们是否有自制力去采取这种行为?

Does the person take satisfaction from the right behavior or dislike the wrong behavior? Do they have the self-control to engage in the behavior when it’s hardest to do so?

我和他们交谈过,他们喜欢做 TDD。

I spoke with them, and they like doing TDD.

社交能力

Social ability

您或其他人是否提供该人所需的帮助、信息和资源,特别是在关键时刻?

Do you or others provide the help, information, and resources required by that person, particularly at critical times?

是的。

Yes.

社会动机

Social motivation

他们周围的人是否积极鼓励正确的行为并阻止错误的行为?您或其他人是否以有效的方式树立正确的行为?

Are the people around them actively encouraging the right behavior and discouraging the wrong behavior?Are you or others modeling the right behavior in an effective way?

越多越好。

As much as possible.

结构(环境)能力

Structural (environmental) ability

环境中的某些方面(建筑、预算等)是否使行为变得方便、轻松和安全?是否有足够的提示和提醒来保持正确的方向?

Are there aspects in the environment (building, budget, and so on) that make the behavior convenient, easy, and safe?Are there enough cues and reminders to stay on course?

他们没有构建机器的预算。*

They don’t have a budget for a build machine.*

结构动机

Structural motivation

当您或其他人的行为正确或错误时,是否有明确且有意义的奖励(例如工资、奖金或激励)?短期奖励是否与您想要强化或想要避免的预期长期结果和行为相匹配?

Are there clear and meaningful rewards (such as pay, bonuses, or incentives) when you or others behave the right or wrong way?Do short-term rewards match the desired long-term results and behaviors you want to reinforce or want to avoid?

当他们试图花时间进行单元测试时,他们的经理告诉他们这是在浪费时间。如果他们发货早而且质量很差,他们就会得到奖金。*

When they try to spend time unit testing, their managers tell them they’re wasting time. If they ship early and crappy, they get a bonus.*

我在右栏中需要工作的项目旁边添加了星号。在这里我确定了两个需要解决的问题。仅解决构建机器预算问题不会改变行为。他们必须拥有一台构建机器,并阻止他们的经理因为快速运送蹩脚的东西而给予奖金。

I put asterisks next to the items in the right column that require work. Here I’ve identified two issues that need to be resolved. Solving only the build machine budget problem won’t change the behavior. They have to get a build machine and deter their managers from giving a bonus on shipping crappy stuff quickly.

我在《Notes to a Software Team Leader》Team Agile Publishing,2014 年)中写了更多相关内容,这是一本关于运营技术团队的书。您可以在5whys.com上找到它。

I write much more on this in Notes to a Software Team Leader (Team Agile Publishing, 2014), a book about running a technical team. You can find it at 5whys.com.

11.5 棘手的问题和答案

11.5 Tough questions and answers

本节涵盖了我在不同地方遇到的一些问题。它们通常源于这样的前提:实施单元测试可能会伤害某人个人——担心他们的最后期限的经理或担心他们的相关性的 QA 员工。一旦您了解了问题的来源,直接或间接解决该问题就很重要。否则,总会有微妙的阻力。

This section covers some questions I’ve come across in various places. They usually arise from the premise that implementing unit testing can hurt someone personally—a manager concerned about their deadlines or a QA employee concerned about their relevance. Once you understand where a question is coming from, it’s important to address the issue, directly or indirectly. Otherwise, there will always be subtle resistance.

11.5.1 单元测试会给当前流程增加多少时间?

11.5.1 How much time will unit testing add to the current process?

团队领导、项目经理和客户通常会问单元测试会给流程增加多少时间。他们是时间上最前线的人。

Team leaders, project managers, and clients are the ones who usually ask how much time unit testing will add to the process. They’re the people at the front lines in terms of timing.

让我们从一些事实开始。研究表明,提高项目的整体代码质量可以提高生产力并缩短工期。这与编写测试会使编码变慢的事实相匹配吗?主要是通过可维护性和修复错误的容易性。

Let’s begin with some facts. Studies have shown that raising the overall code quality in a project can increase productivity and shorten schedules. How does this match up with the fact that writing tests makes coding slower? Through maintainability and the ease of fixing bugs, mostly.

注意有关代码质量和生产力的研究,请参阅《编程生产力》 McGraw-Hill College,1986 年)和《软件评估、基准测试和最佳实践》 Addison-Wesley Professional,2000 年),均由 Capers Jones 编写。

Note For studies on code quality and productivity, see Programming Productivity (McGraw-Hill College, 1986) and Software Assessments, Benchmarks, and Best Practices (Addison-Wesley Professional, 2000), both by Capers Jones.

当询问时间时,团队领导可能真的会问:“当我们超出了截止日期时,我应该告诉我的项目经理什么?” 他们实际上可能认为这个过程很有用,但正在为即将到来的战斗寻找弹药。他们提出的问题也可能不是针对整个产品,而是针对特定的特性集或功能。另一方面,询问时间安排的项目经理或客户通常会谈论完整的产品发布。

When asking about time, team leaders may really be asking, “What should I tell my project manager when we go way past our due date?” They may actually think the process is useful but be looking for ammunition for the upcoming battle. They may also be asking the question not in terms of the whole product but in terms of specific feature sets or functionality. A project manager or customer who asks about timing, on the other hand, will usually be talking in terms of full product releases.

因为不同的人关心的范围不同,所以你的答案可能会有所不同。例如,单元测试可以使实现特定功能所需的时间加倍,但产品的总体发布日期实际上可能会缩短。为了理解这一点,让我们看一个我参与过的真实例子。

Because different people care about different scopes, your answers may vary. For example, unit testing can double the time it takes to implement a specific feature, but the overall release date for the product may actually be reduced. To understand this, let’s look at a real example I was involved with.

两个特征的故事

A tale of two features

我咨询过的一家大公司希望在他们的流程中实施单元测试,从试点项目开始。该试点由一组开发人员组成,向大型现有应用程序添加新功能。该公司的主要生计是创建这个大型计费应用程序并为不同的客户定制其中的部分内容。该公司在全球拥有数千名开发人员。

A large company I consulted with wanted to implement unit testing in their process, beginning with a pilot project. The pilot consisted of a group of developers adding a new feature to a large existing application. The company’s main livelihood was in creating this large billing application and customizing parts of it for various clients. The company had thousands of developers around the world.

为了检验试点的成功,采取了以下措施:

The following measures were taken to test the pilot’s success:

  • 团队在每个开发阶段花费的时间

  • The time the team spent on each of the development stages

  • 项目发布给客户的总时间

  • The overall time for the project to be released to the client

  • 发布后客户端发现的Bug数量

  • The number of bugs found by the client after the release

对于不同团队为不同客户创建的类似功能收集了相同的统计数据。这两个功能的大小几乎相同,团队的技能和经验水平也大致相同。这两项任务都是定制工作——一项带有单元测试,另一项则没有。表 11.2 显示了时间上的差异。

The same statistics were collected for a similar feature created by a different team for a different client. The two features were nearly the same size, and the teams were roughly at the same skill and experience level. Both tasks were customization efforts—one with unit tests, the other without. Table 11.2 shows the differences in time.

表 11.2 有和没有测试测量的团队进度和产出

Table 11.2 Team progress and output measured with and without tests

阶段

Stage

没有测试的团队

Team without tests

团队进行测试

Team with tests

实施(编码)

Implementation (coding)

7天

7 days

14天

14 days

一体化

Integration

7天

7 days

2天

2 days

测试和错误修复

Testing and bug fixing

测试,3 天修复,3 天测试,3 天修复,2 天测试,1 天总计:12 天

Testing, 3 days Fixing, 3 days Testing, 3 days Fixing, 2 days Testing, 1 dayTotal: 12 days

测试、3 天修复、1 天测试、1 天修复、1 天测试、1 天总计:7 天

Testing, 3 days Fixing, 1 dayTesting, 1 dayFixing, 1 dayTesting, 1 dayTotal: 7 days

整体发布时间

Overall release time

26天

26 days

23天

23 days

生产中发现的错误

Bugs found in production

71

71

11

11

总体而言,经过测试的发布所花费的时间比未经测试的要少。尽管如此,进行单元测试的团队的经理最初并不相信试点会成功,因为他们只将实施(编码)统计数据(表 11.2 中的第一行)视为成功的标准,而不是底线。编写该功能花费了两倍的时间(因为单元测试要求您编写更多代码)。尽管如此,当 QA 团队发现需要处理的错误较少时,额外的时间还是得到了补偿。

Overall, the time it took to release with tests was less than without tests. Still, the managers on the team with unit tests didn’t initially believe the pilot would be a success, because they only looked at the implementation (coding) statistic (the first row in table 11.2) as the criteria for success, instead of the bottom line. It took twice the amount of time to code the feature (because unit tests require you to write more code). Despite this, the extra time was more than compensated for when the QA team found fewer bugs to deal with.

这就是为什么需要强调的是,尽管单元测试会增加实现功能所需的时间,但由于质量和可维护性的提高,总体时间要求在产品的发布周期中得到了平衡。

That’s why it’s important to emphasize that although unit testing can increase the amount of time it takes to implement a feature, the overall time requirements balance out over the product’s release cycle because of increased quality and maintainability.

11.5.2 我的 QA 工作会因为单元测试而面临风险吗?

11.5.2 Will my QA job be at risk because of unit testing?

单元测试并不会消除与 QA 相关的工作。QA 工程师将收到带有完整单元测试套件的应用程序,这意味着他们可以在开始自己的测试过程之前确保所有单元测试都通过。进行单元测试实际上会让他们的工作变得更有趣。他们将能够专注于在现实场景中查找更多逻辑(适用)错误,而不是进行 UI 调试(每单击一次按钮就会导致某种异常)。单元测试提供了针对错误的第一层防御,QA 工作提供了第二层——用户接受层。与安全性一样,应用程序始终需要具有不止一层的保护。让 QA 流程专注于更大的问题可以产生更好的应用程序。

Unit testing doesn’t eliminate QA-related jobs. QA engineers will receive the application with full unit test suites, which means they can make sure all the unit tests pass before they start their own testing process. Having unit tests in place will actually make their job more interesting. Instead of doing UI debugging (where every second button click results in an exception of some sort), they’ll be able to focus on finding more logical (applicative) bugs in real-world scenarios. Unit tests provide the first layer of defense against bugs, and QA work provides the second layer—the user acceptance layer. As with security, the application always needs to have more than one layer of protection. Allowing the QA process to focus on the larger issues can produce better applications.

在某些地方,QA 工程师编写代码,他们可以帮助为应用程序编写单元测试。这与应用程序开发人员的工作一起发生,而不是代替它。开发人员和 QA 工程师都可以编写单元测试。

In some places, QA engineers write code, and they can help write unit tests for the application. That happens in conjunction with the work of the application developers and not instead of it. Both developers and QA engineers can write unit tests.

11.5.3 有证据表明单元测试有帮助吗?

11.5.3 Is there proof that unit testing helps?

我可以指出,没有任何关于单元测试是否有助于实现更好的代码质量的具体研究。大多数相关研究都讨论采用特定的敏捷方法,单元测试只是其中之一。一些经验证据可以从网络上收集到,公司和同事取得了很好的成果,并且在没有测试的情况下永远不想回到代码库。有关 TDD 的一些研究可以在 QA Lead 中找到: http: //mng.bz/dddo

There aren’t any specific studies on whether unit testing helps achieve better code quality that I can point to. Most related studies talk about adopting specific agile methods, with unit testing being just one of them. Some empirical evidence can be gleaned from the web, of companies and colleagues having great results and never wanting to go back to a codebase without tests. A few studies on TDD can be found at The QA Lead here: http://mng.bz/dddo.

11.5.4 为什么 QA 部门仍然发现 bug?

11.5.4 Why is the QA department still finding bugs?

您可能不再有 QA 部门,但这仍然是一种非常普遍的做法。不管怎样,你仍然会发现错误。请使用多个级别的测试(如第 10 章中所述),以获得跨应用程序多个层的信心。单元测试为您提供快速反馈和易于维护,但它们会留下一些信心,而这只能通过某些级别的集成测试来获得。

You may not have a QA department anymore, but this is still a very prevalent practice. Either way, you’ll still be finding bugs. Please use tests at multiple levels, as described in chapter 10, to gain confidence across many layers of your application. Unit tests give you fast feedback and easy maintainability, but they leave some confidence behind, which can only be gained through some levels of integration tests.

11.5.5 我们有大量未经测试的代码:我们从哪里开始?

11.5.5 We have lots of code without tests: Where do we start?

20 世纪 70 年代和 80 年代进行的研究表明,通常 80% 的错误出现在 20% 的代码中。诀窍是找到问题最多的代码。通常,任何团队都可以告诉您哪些组件问题最严重。从那里开始。您始终可以添加一些与每个类的错误数量相关的指标。

Studies conducted in the 1970s and 1980s showed that, typically, 80% of bugs are found in 20% of the code. The trick is to find the code that has the most problems. More often than not, any team can tell you which components are the most problematic. Start there. You can always add some metrics related to the number of bugs per class.

80/20 数字的来源

Sources for the 80/20 figure

研究表明 80% 的错误存在于 20% 的代码中,包括:Albert Endres,“系统程序中错误及其原因的分析”,IEEE Transactions on Software Engineering 2(1975 年 6 月),140-49;Lee L. Gremillion,“程序维修维护要求的决定因素”,ACM 27 通讯,第 1 期。8(1984 年 8 月),826-32;Barry W. Boehm,“工业软件指标前 10 名列表”,IEEE Software 4,第 1 期。9(1987 年 9 月)、84-85(转载于 IEEE 时事通讯并可在http://mng.bz/rjjJ在线获取);Shull 等人,“我们在对抗缺陷方面学到了什么”,第八届软件度量国际研讨会论文集(2002 年),249-58。

Studies that show 80% of the bugs are in 20% of the code include the following: Albert Endres, “An analysis of errors and their causes in system programs,” IEEE Transactions on Software Engineering 2 (June 1975), 140-49; Lee L. Gremillion, “Determinants of program repair maintenance requirements,” Communications of the ACM 27, no. 8 (August 1984), 826-32; Barry W. Boehm, “Industrial software metrics top 10 list,” IEEE Software 4, no. 9 (September 1987), 84-85 (reprinted in an IEEE newsletter and available online at http://mng.bz/rjjJ); and Shull and others, “What we have learned about fighting defects,” Proceedings of the 8th International Symposium on Software Metrics (2002), 249-58.

测试遗留代码需要采用与通过测试编写新代码不同的方法。详细信息请参见第 12 章。

Testing legacy code requires a different approach than when writing new code with tests. See chapter 12 for more details.

11.5.6 如果我们开发软件和硬件的组合会怎样?

11.5.6 What if we develop a combination of software and hardware?

即使您开发软件和硬件的组合,也可以使用单元测试。查看上一章中提到的测试层,确保涵盖软件和硬件。硬件测试通常需要使用不同级别的模拟器和仿真器,但通常的做法是对低级嵌入式和高级代码进行一套测试。

You can use unit tests even if you develop a combination of software and hardware. Look into the test layers mentioned in the previous chapter to make sure you cover both software and hardware. Hardware testing usually requires the use of simulators and emulators at various levels, but it is a common practice to have a suite of tests both for low-level embedded and high-level code.

11.5.7 我们如何知道我们的测试中没有错误?

11.5.7 How can we know we don’t have bugs in our tests?

您需要确保您的测试在应该失败的时候失败,在应该通过的时候通过。TDD 是确保您不会忘记检查这些事情的好方法。请参阅第 1 章,了解 TDD 的简要介绍。

You need to make sure your tests fail when they should and pass when they should. TDD is a great way to make sure you don’t forget to check those things. See chapter 1 for a short walk-through of TDD.

11.5.8 如果我的调试器显示我的代码可以工作,为什么还需要测试?

11.5.8 Why do I need tests if my debugger shows that my code works?

调试器对于多线程代码没有多大帮助。另外,您可能确定您的代码工作正常,但是其他人的代码呢?你怎么知道它有效?他们如何知道您的代码可以工作并且在进行更改时没有破坏任何内容?请记住,编码是代码生命周期的第一步。代码在其生命周期的大部分时间都处于维护模式。您需要使用单元测试确保它会在发生故障时告诉人们。

Debuggers don’t help much with multithreaded code. Also, you may be sure your code works fine, but what about other people’s code? How do you know it works? How do they know your code works and that they haven’t broken anything when they make changes? Remember that coding is the first step in the life of the code. Most of its life, the code will be in maintenance mode. You need to make sure it will tell people when it breaks, using unit tests.

Curtis、Krasner 和 Iscoe 进行的一项研究(“大型系统软件设计过程的现场研究”,Communications of the ACM 31,第 11 期(1988 年 11 月),1268-87)表明,大多数缺陷并不来自代码本身,但由于人们之间的沟通不畅、需求不断变化以及缺乏应用程序领域知识而导致。即使您是世界上最伟大的程序员,如果有人告诉您编写错误的代码,您也很可能会这样做。当您需要更改它时,您会很高兴对其他所有内容进行了测试,以确保不会破坏它。

A study held by Curtis, Krasner, and Iscoe (“A field study of the software design process for large systems,” Communications of the ACM 31, no. 11 (November 1988), 1268-87) showed that most defects don’t come from the code itself but result from miscommunication between people, requirements that keep changing, and a lack of application domain knowledge. Even if you’re the world’s greatest coder, chances are that if someone tells you to code the wrong thing, you’ll do it. When you need to change it, you’ll be glad you have tests for everything else, to make sure you don’t break it.

11.5.9 TDD 怎么样?

11.5.9 What about TDD?

TDD 是一种风格选择。我个人认为 TDD 有很大的价值,许多人发现它富有成效且有益,但其他人发现在代码之后编写测试对他们来说已经足够好了。您可以自行选择。

TDD is a style choice. I personally see a lot of value in TDD, and many people find it productive and beneficial, but others find that writing tests after the code is good enough for them. You can make your own choice.

概括

Summary

  • 在他们的组织中实施单元测试是本书的许多读者有时不得不面对的事情。

  • Implementing unit testing in their organization is something that many readers of this book will have to face at one time or another.

  • 确保您不会疏远可以帮助您的人。识别组织内部的拥护者和阻碍者。让这两个群体都成为变革过程的一部分。

  • Make sure that you don’t alienate the people who can help you. Recognize champions and blockers inside the organization. Make both groups part of the change process.

  • 确定可能的起点。从范围有限的小团队或项目开始,以获得快速胜利并最大限度地减少项目工期风险。

  • Identify possible starting points. Start with a small team or project with a limited scope to get a quick win and minimize project duration risks.

  • 让每个人都能看到进展。瞄准具体目标、指标和 KPI。

  • Make the progress visible to everyone. Aim for specific goals, metrics, and KPIs.

  • 注意失败的潜在原因,例如缺乏驱动力以及缺乏政治或团队支持。

  • Take note of potential causes of failure, such as the lack of a driving force and lack of political or team support.

  • 准备好对您可能会被问到的问题提供良好的答案

  • Be prepared to have good answers to the questions you’re likely to be asked.

12 使用遗留代码

12 Working with legacy code

本章涵盖

This chapter covers

  • 检查遗留代码的常见问题
  • Examining common problems with legacy code
  • 决定从哪里开始编写测试
  • Deciding where to begin writing tests

我曾经为一家生产计费软件的大型开发商店提供咨询。他们拥有超过 10,000 名开发人员,并在产品、子产品和相互交织的项目中混合使用 .NET、Java 和 C++。该软件已经以某种形式存在了五年多,大多数开发人员的任务是维护和构建现有功能。

I once consulted for a large development shop that produced billing software. They had over 10,000 developers and mixed .NET, Java, and C++ in products, subproducts, and intertwined projects. The software had existed in one form or another for over five years, and most of the developers were tasked with maintaining and building on top of existing functionality.

我的工作是帮助多个部门(使用所有语言)学习 TDD 技术。对于与我合作的大约 90% 的开发人员来说,由于多种原因,这从未成为现实,其中一些是遗留代码的结果:

My job was to help several divisions (using all languages) learn TDD techniques. For about 90% of the developers I worked with, this never became a reality for several reasons, some of which were a result of legacy code:

  • 针对现有代码编写测试很困难。

  • It was difficult to write tests against existing code.

  • 重构现有代码几乎是不可能的(或者没有足够的时间来做到这一点)。

  • It was next to impossible to refactor the existing code (or there wasn’t enough time to do it).

  • 有些人不想改变他们的设计。

  • Some people didn’t want to change their designs.

  • 工具(或缺乏工具)成为了障碍。

  • Tooling (or a lack of tooling) was getting in the way.

  • 很难决定从哪里开始。

  • It was difficult to determine where to begin.

任何曾经尝试向现有系统添加测试的人都知道,大多数此类系统几乎不可能为其编写测试。它们通常在软件中没有适当的位置(称为接缝)来编写,以允许扩展或替换现有组件。

Anyone who’s ever tried to add tests to an existing system knows that most such systems are almost impossible to write tests for. They were usually written without proper places (called seams) in the software to allow extensions or replacements to existing components.

处理遗留代码时需要解决两个问题:

There are two problems that need to be addressed when dealing with legacy code:

  • 工作量如此之多,应该从哪里开始添加测试呢?你应该把精力集中在哪里?

  • There’s so much work, where should you start to add tests? Where should you focus your efforts?

  • 如果代码一开始就没有测试,那么如何安全地重构代码呢?

  • How can you safely refactor your code if it has no tests to begin with?

本章将通过列出有帮助的技术、参考资料和工具来解决与处理遗留代码库相关的棘手问题。

This chapter will tackle these tough questions associated with approaching legacy codebases by listing techniques, references, and tools that can help.

12.1 从哪里开始添加测试?

12.1 Where do you start adding tests?

假设您的组件内已有代码,您需要创建一个组件的优先级列表,对这些组件进行测试最有意义。有几个因素需要考虑,这些因素可能会影响每个组件的优先级:

Assuming you have existing code inside components, you’ll need to create a priority list of components for which testing makes the most sense. There are several factors to consider that can affect each component’s priority:

  • 逻辑复杂性——这是指组件中的逻辑量,例如嵌套if、switch case 或递归。这种复杂度也称为圈复杂度,您可以使用各种工具自动检查它。

  • Logical complexity—This refers to the amount of logic in the component, such as nested ifs, switch cases, or recursion. Such complexity is also called cyclomatic complexity, and you can use various tools to check it automatically.

  • 依赖级别——这是指组件中依赖的数量。为了测试这个类,你必须打破多少依赖关系?它是否与外部电子邮件组件通信,或者是否在某处调用静态日志方法?

  • Dependency level—This refers to the number of dependencies in the component. How many dependencies do you have to break in order to bring this class under test? Does it communicate with an outside email component, perhaps, or does it call a static log method somewhere?

  • 优先级——这是组件在项目中的一般优先级。

  • Priority—This is the component’s general priority in the project.

您可以为每个组件对这些因素进行评级,从 1(低优先级)到 10(高优先级)。表 12.1 显示了这些因素的评级类别。我称之为测试可行性表

You can give each component a rating for these factors, from 1 (low priority) to 10 (high priority). Table 12.1 shows classes with ratings for these factors. I call this a test-feasibility table.

表 12.1 一个简单的测试可行性表

Table 12.1 A simple test-feasibility table

成分

Component

逻辑复杂性

Logical complexity

依赖级别

Dependency level

优先事项

Priority

笔记

Notes

Utils

Utils

6

6

1

1

5

5

该实用程序类几乎没有依赖项,但包含大量逻辑。它将很容易测试,并且提供很多价值。

This utility class has few dependencies but contains a lot of logic. It will be easy to test, and it provides lots of value.

Person

Person

2

2

1

1

1

1

这是一个数据持有者类,几乎没有逻辑且没有依赖性。测试这个没有什么实际价值。

This is a data-holder class with little logic and no dependencies. There’s little real value in testing this.

TextParser

TextParser

8

8

4

4

6

6

这个类有很多逻辑和很多依赖关系。最重要的是,这是该项目中高优先级任务的一部分。测试这一点将提供很多价值,但也将是困难且耗时的。

This class has lots of logic and lots of dependencies. To top it off, it’s part of a high-priority task in the project. Testing this will provide lots of value but will also be hard and time consuming.

ConfigManager

ConfigManager

1

1

6

6

1

1

此类保存配置数据并从磁盘读取文件。它几乎没有逻辑,但有很多依赖性。测试它对项目几乎没有什么价值,而且也很困难且耗时。

This class holds configuration data and reads files from disk. It has little logic but many dependencies. Testing it will provide little value to the project and will also be hard and time consuming.

根据表 12.1 中的数据,您可以创建一个如图 12.1 所示的图表,该图表按照项目的价值量和依赖项数量来绘制您的组件。您可以安全地忽略低于指定逻辑阈值(我通常设置为 2 或 3)的项目,因此PersonConfigManager可以被忽略。您只剩下图 12.1 中最上面的两个组件。

From the data in table 12.1, you can create a diagram like the one shown in figure 12.1, which graphs your components by the amount of value to the project and number of dependencies. You can safely ignore items that are below your designated threshold of logic (which I usually set at 2 or 3), so Person and ConfigManager can be ignored. You’re left with only the top two components in figure 12.1.

 

 

12-01



图 12.1 测试可行性的映射组件

Figure 12.1 Mapping components for test feasibility

有两种基本方法可以查看图表并决定首先要测试什么(见图 12.2):

There are two basic ways to look at the graph and decide what you’d like to test first (see figure 12.2):

  • 选择更复杂且更容易测试的一个(左上)。

  • Choose the one that’s more complex and easier to test (top left).

  • 选择更复杂且更难测试的一个(右上)。

  • Choose the one that’s more complex and harder to test (top right).

12-02



图12.2 基于逻辑和依赖关系的简单、困难和不相关组件映射

Figure 12.2 Easy, hard, and irrelevant component mapping based on logic and dependencies

现在的问题是你应该走哪条路。你应该从简单的事情开始还是从困难的事情开始?

The question now is what path you should take. Should you start with the easy stuff or the hard stuff?

12.2 选择选择策略

12.2 Choosing a selection strategy

正如上一节所解释的,您可以从易于测试的组件开始,也可以从难以测试的组件开始(因为它们有很多依赖项)。每种策略都会带来不同的挑战。

As the previous section explained, you can start with the components that are easy to test or the ones that are hard to test (because they have many dependencies). Each strategy presents different challenges.

12.2.1 简单优先策略的优缺点

12.2.1 Pros and cons of the easy-first strategy

从依赖项较少的组件开始将使最初编写测试变得更快更容易。但有一个问题,如图 12.3 所示。

Starting out with the components that have fewer dependencies will make writing the tests initially much quicker and easier. But there’s a catch, as figure 12.3 demonstrates.

12-03



图 12.3 当从简单的组件开始时,测试组件所需的时间会越来越多,直到完成最难的组件。

Figure 12.3 When starting with the easy components, the time required to test components increases more and more until the hardest components are done.

图 12.3 显示了在项目生命周期内对组件进行测试需要多长时间。最初编写测试很容易,但随着时间的推移,您留下的组件越来越难测试,特别困难的组件在项目周期结束时等待着您,就在每个人都感到压力的时候将产品推出市场。

Figure 12.3 shows how long it takes to bring components under test during the lifetime of the project. Initially it’s easy to write tests, but as time goes by, you’re left with components that are increasingly harder and harder to test, with the particularly tough ones waiting for you at the end of the project cycle, just when everyone is stressed about pushing a product out the door.

如果您的团队对单元测试技术相对较新,那么值得从简单的组件开始。随着时间的推移,团队将学习处理更复杂的组件和依赖项所需的技术。对于这样的团队,明智的做法是首先避免所有组件超过特定数量的依赖项(合理的限制是四个)。

If your team is relatively new to unit testing techniques, it’s worth starting with the easy components. As time goes by, the team will learn the techniques needed to deal with the more complex components and dependencies. For such a team, it may be wise to initially avoid all components over a specific number of dependencies (with four being a reasonable limit).

12.2.2 硬优先策略的优缺点

12.2.2 Pros and cons of the hard-first strategy

从更困难的组件开始似乎是一个失败的提议,但只要您的团队拥有单元测试技术的经验,它就有一个好处。图 12.4 显示了在项目的生命周期内为单个组件编写测试的平均时间(如果您首先开始测试具有最多依赖项的组件)。

Starting with the more difficult components may seem like a losing proposition initially, but it has an upside as long as your team has experience with unit testing techniques. Figure 12.4 shows the average time to write a test for a single component over the lifetime of the project, if you start testing the components with the most dependencies first.

12-04



图 12.4 当您使用“硬优先”策略时,测试组件所需的时间最初很长,但随着更多依赖项被重构而减少。

Figure 12.4 When you use a hard-first strategy, the time required to test components is initially high, but then decreases as more dependencies are refactored away.

通过这种策略,您可能需要花费一天或更长时间才能对更复杂的组件进行最简单的测试。但请注意,相对于图 12.3 中的缓慢增长,编写测试所需的时间快速下降。每次您测试一个组件并重构它以使其更具可测试性时,您还可能正在解决它使用的依赖项或其他组件的可测试性问题。由于该组件有很多依赖项,因此重构它可以改进系统其他部分的功能。这就是快速下降的原因。

With this strategy, you could be spending a day or more to get even the simplest tests going on the more complex components. But notice the quick decline in the time required to write the tests relative to the slow incline in figure 12.3. Every time you bring a component under test and refactor it to make it more testable, you may also be solving testability issues for the dependencies it uses or for other components. Because that component has lots of dependencies, refactoring it can improve things for other parts of the system. That’s the reason for the quick decline.

只有当您的团队具有单元测试技术经验时,“困难优先”策略才可能实现,因为它更难实施。如果您的团队确实有经验,请使用组件的优先级来选择是从困难组件还是从简单组件开始。您可能想要选择一种组合,但重要的是您必须提前知道需要付出多少努力以及可能的后果是什么。

The hard-first strategy is only possible if your team has experience in unit testing techniques, because it’s harder to implement. If your team does have experience, use the priority aspect of components to choose whether to start with the hard or easy components. You might want to choose a mix, but it’s important that you know in advance how much effort will be involved and what the possible consequences are.

12.3 重构前编写集成测试

12.3 Writing integration tests before refactoring

如果您确实计划重构代码以实现可测试性(以便可以编写单元测试),那么确保在重构​​阶段不会破坏任何内容的实用方法是针对生产系统编写集成式测试。

If you do plan to refactor your code for testability (so you can write unit tests), a practical way to make sure you don’t break anything during the refactoring phase is to write integration-style tests against your production system.

我为一个大型遗留项目提供咨询,与一位需要使用 XML 配置管理器的开发人员合作。该项目没有测试并且很难测试。这也是一个 C++ 项目,因此我们无法使用工具在不重构代码的情况下轻松地将组件与依赖项隔离。

I consulted on a large legacy project, working with a developer who needed to work on an XML configuration manager. The project had no tests and was hardly testable. It was also a C++ project, so we couldn’t use a tool to easily isolate components from dependencies without refactoring the code.

开发人员需要在 XML 文件中添加另一个值属性,并能够通过现有的配置组件读取和更改它。我们最终编写了几个集成测试,这些测试使用真实系统来保存和加载配置数据,并对配置组件正在检索和写入文件的值进行断言。这些测试将配置管理器的“原始”工作行为设置为我们的工作基础。

The developer needed to add another value attribute into the XML file and be able to read and change it through the existing configuration component. We ended up writing a couple of integration tests that used the real system to save and load configuration data and that asserted on the values the configuration component was retrieving and writing to the file. Those tests set the “original” working behavior of the configuration manager as our base of work.

接下来,我们编写了一个集成测试,该测试表明,一旦组件读取文件,它在内存中不包含具有我们尝试添加的名称的属性。我们证明了该功能缺失,现在我们有了一个测试,一旦我们将新属性添加到 XML 文件并从组件正确写入该文件,该测试就会通过。

Next, we wrote an integration test that showed that once the component was reading the file, it contained no attribute in memory with the name we were trying to add. We proved that the feature was missing, and we now had a test that would pass once we added the new attribute to the XML file and correctly wrote to it from the component.

一旦我们编写了保存和加载额外属性的代码,我们就运行了三个集成测试(两个测试针对原始基本实现,另一个测试尝试读取新属性)。这三个都通过了,所以我们知道在添加新功能时我们没有破坏现有功能。

Once we wrote the code that saved and loaded the extra attribute, we ran the three integration tests (two tests for the original base implementation and a new one that tried to read the new attribute). All three passed, so we knew that we hadn’t broken existing functionality while adding the new functionality.

如您所见,该过程相对简单:

As you can see, the process is relatively simple:

  • 向系统添加一项或多项集成测试(无模拟或桩),以证明原始系统按需要工作。

  • Add one or more integration tests (no mocks or stubs) to the system to prove the original system works as needed.

  • 对您尝试添加到系统的功能进行重构或添加失败的测试。

  • Refactor or add a failing test for the feature you’re trying to add to the system.

  • 小块地重构和更改系统,并尽可能频繁地运行集成测试,看看是否破坏了某些内容。

  • Refactor and change the system in small chunks, and run the integration tests as often as you can, to see if you break something.

有时,集成测试可能看起来比单元测试更容易编写,因为您不需要了解代码的内部结构或在哪里注入各种依赖项。但是,在本地系统上运行这些测试可能会很烦人或很耗时,因为您必须确保系统所需的每一件小事都已就位。

Sometimes, integration tests may seem easier to write than unit tests, because you don’t need to understand the internal structure of the code or where to inject various dependencies. But making those tests run on your local system may prove annoying or time consuming because you have to make sure every little thing the system needs is in place.

诀窍是处理系统中需要修复或添加功能的部分。不要把注意力集中在其他部分。这样,系统就会在正确的地方发展,而当你到达其他桥梁时,就需要跨越其他桥梁。

The trick is to work on the parts of the system that you need to fix or add features to. Don’t focus on the other parts. That way, the system grows in the right places, leaving other bridges to be crossed when you get to them.

随着您继续添加越来越多的测试,您可以重构系统并向其添加更多单元测试,将其发展为更易于维护和测试的系统。这需要时间(有时需要数月),但这是值得的。

As you continue adding more and more tests, you can refactor the system and add more unit tests to it, growing it into a more maintainable and testable system. This takes time (sometimes months and months), but it’s worth it.

Vladimir Khorikov 的《单元测试原理、实践和模式》 (Manning,2020 年)第 7 章包含此类重构的深入示例。请参阅该书了解更多详细信息。

Chapter 7 of Unit Testing Principles, Practices, and Patterns by Vladimir Khorikov (Manning, 2020) contains an in-depth example of such refactoring. Refer to that book for more details.

12.3.1 阅读 Michael Feathers 的有关遗留代码的书

12.3.1 Read Michael Feathers’ book on legacy code

Michael Feathers 的《有效处理遗留代码》(Pearson,2004 年)是另一个有价值的资源,它处理了您在处理遗留代码时遇到的问题。它深入展示了本书不试图涵盖的许多重构技术和陷阱。它的价值相当于黄金的重量。得到它。

Working Effectively with Legacy Code by Michael Feathers (Pearson, 2004) is another valuable source that deals with the issues you’ll encounter with legacy code. It shows many refactoring techniques and gotchas in depth that this book doesn’t attempt to cover. It’s worth its weight in gold. Get it.

12.3.2 使用 CodeScene 调查您的生产代码

12.3.2 Use CodeScene to investigate your production code

另一个名为 CodeScene 的工具可以让您发现遗留代码中的大量技术债务和隐藏问题等。它是一个商业工具,虽然我没有亲自使用过它,但我听说过一些很棒的东西。您可以在https://codescene.com/了解更多信息。

Another tool called CodeScene allows you to discover lots of technical debt and hidden issues in legacy code, among many other things. It is a commercial tool, and while I have not personally used it, I've heard great things. You can learn more about it at https://codescene.com/.

概括

Summary

  • 在开始为遗留代码编写测试之前,重要的是根据各个组件的依赖项数量、逻辑量以及每个组件在项目中的一般优先级来规划它们。组件的逻辑复杂度(或圈复杂度)是指组件中的逻辑量,例如嵌套if、switch case 或递归。

  • Before starting to write tests for legacy code, it’s important to map out the various components according to their number of dependencies, their amount of logic, and each component’s general priority in the project. A component’s logical complexity (or cyclomatic complexity) refers to the amount of logic in the component, such as nested ifs, switch cases, or recursion.

  • 获得这些信息后,您可以根据测试组件的难易程度来选择要使用的组件。

  • Once you have that information, you can choose the components to work on based on how easy or how hard it will be to get them under test.

  • 如果您的团队在单元测试方面经验很少或根本没有,那么最好从简单的组件开始,并随着向系统添加越来越多的测试而增强团队的信心。

  • If your team has little or no experience in unit testing, it’s a good idea to start with the easy components and let the team’s confidence grow as they add more and more tests to the system.

  • 如果您的团队经验丰富,那么首先测试硬组件可以帮助您更快地完成系统的其余部分。

  • If your team is experienced, getting the hard components under test first can help you get through the rest of the system more quickly.

  • 在大规模重构之前,编写集成测试以维持重构大部分不变。重构完成后,用更小、更易于维护的单元测试替换大部分集成测试。

  • Before a large-scale refactoring, write integration tests that will sustain that refactoring mostly unchanged. After the refactoring is completed, replace most of these integration tests with smaller and more maintainable unit tests.

附录。猴子修补功能和模块

appendix. Monkey-patching functions and modules

在第 3 章中,我介绍了各种我称之为“已接受”的桩技术,因为它们通常被认为对于代码的可维护性和可读性以及它们指导我们编写的测试来说是安全的。在本附录中,我将描述一些不太被接受且不太安全的方法,我们可以通过这些方法在测试中伪造整个模块。

In chapter 3, I introduced various stubbing techniques that I called “accepted,” in that they are usually considered safe for both the maintainability and readability of the code and the tests that they guide us to write. In this appendix, I’ll describe a few of the less accepted and less safe ways in which we can fake whole modules in our tests.

A.1 强制性警告

A.1 An obligatory warning

我有关于全局修补和删除函数和模块的好消息和坏消息。是的,您可以做到——我将向您展示几种实现这一目标的方法。这是个好主意吗?我不相信。根据我的经验,使用我将向您展示的技术维护测试的成本往往比维护参数化良好或内置适当接缝的代码更糟糕。

I have good news and bad news about global patching and stubbing out functions and modules. Yes, you can do it—I’ll show you several ways to accomplish this. Is it a great idea? I’m not convinced. The costs of maintaining your tests with the techniques I’ll show you tend to be, from my experience, worse than maintaining code that is well parameterized or has proper seams built in.

然而,在特殊时期您可能需要使用这些技术。这种情况包括但不限于在您不拥有且无法更改的代码中伪造依赖项,有时在使用立即可执行的函数或模块时。另一种情况是,模块仅公开函数而不公开对象,这在很大程度上限制了伪造选项。

However, there might be special times when you need to use these techniques. Such times include, but are not limited to, faking dependencies in code that you do not own and cannot change, and sometimes when using immediately executable functions or modules. Another case is when a module exposes only functions without objects, which limits the faking options quite a bit.

尽量避免使用我在本附录中描述的技术。如果您可以找到一种方法来编写测试或重构代码,这样您就不需要这些方法,请使用这种方法。如果所有其他方法都失败了,那么本附录中的技术就是不可避免的邪恶。如果您必须使用它们,请尽量减少使用它们的次数。您的测试将会受到影响,并且会变得更加脆弱且难以阅读。

Try to avoid using the techniques I describe in this appendix as much as you can. If you can find a way to write your tests or refactor your code so you don’t need these approaches, use that way. If all else fails, the techniques in this appendix are a necessary evil. If you must use them, try to minimize how much you use them. Your tests will suffer and will become more fragile and harder to read.

让我们深入了解一下。

Let’s dive in.

A.2 猴子修补函数、全局变量和可能的问题

A.2 Monkey-patching functions, globals, and possible issues

猴子修补是指在运行时更改正在运行的程序实例的行为的行为。我第一次遇到这个术语是在 Ruby 工作时,猴子补丁在 Ruby 中非常常见。在 JavaScript 中,在运行时“修补”函数同样容易。

Monkey-patching refers to the act of changing the behavior of a running program instance at run time. I first encountered the term when I was working in Ruby, where monkey-patching is very common. In JavaScript, it’s just as easy to “patch” a function at run time.

在第三章中,我们研究了测试和代码中的时间管理问题。通过猴子修补,我们可以查看任何函数,无论是全局函数还是局部函数,并用不同的实现替换它(对于特定的 JavaScript 范围)。如果我们想要修补时间,我们可以对全局进行猴子修补Date.now,以便从该点开始的任何代码都会受到此更改的影响,包括生产代码和测试代码。

In chapter 3 we looked at the issue of time management in our tests and code. With monkey-patching, we could look at any function, global or local, and replace it (for a specific JavaScript scope) with a different implementation. If we wanted to patch time, we could monkey-patch the global Date.now so that any code from that point on would be affected by this change, both production and test code.

清单 A.1 显示了对直接使用的原始生产代码执行此操作的测试Date.now。它伪造了全局Date.now函数来在测试过程中控制时间。

Listing A.1 shows a test that does this for the original production code that uses Date.now directly. It fakes the global Date.now function to control time during the test.

清单 A.1 伪造全局的问题Date.now()

Listing A.1 Issues in faking the global Date.now()

描述('v1 findRecentlyRebooted', () => {
  test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
    const OriginalNow = Date.now;             
    const fromDate = new Date(2000,0,3);      
    Date.now = () => fromDate.getTime();      
 
    const restartTwoDaysEarly = new Date(2000,0,1);
    常量机器 = [
      {lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
      {lastBootTime: fromDate, name: '找到' }];
 
    const 结果 = findRecentlyRebooted(machines, 1, fromDate);
 
    期望(结果.长度).toBe(1);
    Expect(result[0].name).toContain('found');
 
    日期.now = 原始现在;                   
  });
});
describe('v1 findRecentlyRebooted', () => {
  test('given 1 of 2 machines under threshold, it is found', () => {
    const originalNow = Date.now;             
    const fromDate = new Date(2000,0,3);      
    Date.now = () => fromDate.getTime();      
 
    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];
 
    const result = findRecentlyRebooted(machines, 1, fromDate);
 
    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');
 
    Date.now = originalNow;                   
  });
}); 

保存原始日期.now

Saving the original Date.now

用自定义日期替换 Date.now

Replacing Date.now with a custom date

恢复原始Date.now

Restoring the original Date.now

在此列表中,我们将全局日期替换Date.now为自定义日期。因为这是一个全局函数,其他测试可能会受到它的影响,所以我们在测试结束时通过将原始函数恢复Date.now到正确的位置来进行清理。

In this listing, we’re replacing the global Date.now with a custom date. Because this is a global function, other tests can be affected by it, so we clean up after ourselves at the end of the test by restoring the original Date.now to its rightful place.

像这样的测试有几个主要问题。首先,这些断言在失败时会抛出异常,这意味着如果它们失败,原始的恢复Date.now可能永远不会被执行,并且其他测试将遭受可能影响它们的“脏”全局时间。

There are several major issues in a test like this. First, these asserts throw exceptions when they fail, which means if they fail, the restoration of the original Date.now might never be executed, and other tests will suffer a “dirty” global time that might affect them.

保存时间功能再放回去也很麻烦。它在考试中留下了印记,并使其变得更长、更难阅读,更难写。很容易忘记重置全局状态。

It’s also cumbersome to save the time function and then put it back. It’s making its mark on the test and making it longer and harder to read, plus harder to write. It’s easy to forget to reset the global state.

最后,我们削弱了并行性。Jest 似乎很好地处理了这个问题,因为它为每个测试文件创建了一组单独的依赖项,但对于可能并行运行测试的其他框架,可能会出现竞争条件。多次测试可以改变或期望全局时间具有一定的值。并行运行时,这些测试可能会发生冲突并在全局状态中产生竞争条件并相互影响。在我们的例子中这不是必需的,但如果您想消除不确定性,Jest 允许您使用额外的命令行参数运行 Jest 命令行--runInBand以避免并行。

Finally, we’ve impaired parallelism. Jest seems to handle this well, as it creates a separate set of dependencies for each test file, but with other frameworks that might run tests in parallel, there could be a race condition. Multiple tests can change or expect the global time to have a certain value. When running in parallel, these tests can collide and create race conditions in the global state and affect each other. It’s not required in our case, but if you wanted to eliminate uncertainty, Jest allows you to run the Jest command line with the extra --runInBand command-line parameter to avoid parallelism.

我们可以通过使用beforeEach()afterEach()辅助函数来避免其中一些问题。

We can avoid some of these issues by resorting to the beforeEach() and afterEach() helper functions.

清单 A.2 诉诸beforeEach()afterEach()

Listing A.2 Resorting to beforeEach() and afterEach()

描述('v2 findRecentlyRebooted',()=> {
  让originalNow; 
  beforeEach(()=>originalNow = Date.now);      ❶afterEach 
  (()=>Date.now=originalNow);       
 
  test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
    const fromDate = new Date(2000,0,3); 
    Date.now = () => fromDate.getTime();
 
    const restartTwoDaysEarly = new Date(2000,0,1);
    常量机器 = [
      {lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
      { lastBootTime: fromDate , name: 'found' }];
 
    const 结果 = findRecentlyRebooted(machines, 1, fromDate);
 
    期望(结果.长度).toBe(1);
    Expect(result[0].name).toContain('found');
  });
});
describe('v2 findRecentlyRebooted', () => {
  let originalNow;
  beforeEach(() => originalNow = Date.now);     
  afterEach(() => Date.now = originalNow);      
 
  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    Date.now = () => fromDate.getTime();
 
    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];
 
    const result = findRecentlyRebooted(machines, 1, fromDate);
 
    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');
  });
});

保存原始日期.now

Saving the original Date.now

恢复原来的Date.now

Restoring the original Date.now

清单 A.2 解决了我们的一些问题,但不是全部。好的部分是我们不再需要记住保存和重置Date.now,因为beforeEach()afterEach()会处理它。现在阅读测试也更容易了。

Listing A.2 solves some of our issues but not all of them. The good part is that we don’t need to remember to save and reset Date.now anymore, because beforeEach() and afterEach() will take care of it. It’s also now easier to read the tests.

但并行测试仍然存在一个潜在的重大问题。Jest 足够智能,可以仅针对每个文件运行并行测试,这意味着此规范文件中的测试将线性运行,但对于其他文件中的测试,不能保证这种行为。任何一项并行测试都可能有自己的beforeEach(),并且afterEach()会重置全局状态,并且可能会在没有意识到的情况下影响我们的测试。

But we still have a potential major issue with parallel tests. Jest is smart enough to run parallel tests only per file, which means the tests in this spec file will run linearly, but this behavior is not guaranteed for tests in other files. Any one of the parallel tests might have their own beforeEach() and afterEach() that reset global state and might affect our tests without realizing it.

当我可以帮助时,我不喜欢伪造全局对象(即大多数类型语言中的“单例”)。总是有附加条件的——额外的编码、额外的维护、额外的测试脆弱性,或者间接影响其他测试以及一直担心清理都是一些原因。大多数时候,当我将接缝因素纳入被测代码的设计中时,而不是像我们刚才所做的那样以隐式方式围绕它,代码会得到更好的结果。

I’m not a fan of faking global objects (i.e., “singletons” in most typed languages) when I can help it. There are always strings attached—extra coding, extra maintenance, extra test fragility, or affecting other tests indirectly and worrying about cleaning up all the time are some reasons why. Most of the time, the code comes out better when I factor seams into the design of the code under test instead of around it in an implicit manner, such as what we just did.

特别是当考虑到越来越多的框架可能开始复制 Jest 的功能并并行运行测试时,全局伪造变得越来越危险。

Especially when considering that more and more frameworks might start to copy Jest’s features and run tests in parallel, global fakes become more and more dangerous.

A.2.1 以 Jest 方式对函数进行猴子修补

A.2.1 Monkey-patching a function the Jest way

为了使图片更加完整,Jest 还通过使用两个协同工作的函数来支持猴子修补的想法:spyOnmockImplementation。这是spyOn

To make the picture more complete, Jest also supports the idea of monkey-patching through the use of two functions that work in tandem: spyOn and mockImplementation. Here’s spyOn:

日期.now =开玩笑间谍(日期,'现在')
Date.now = jest.spyOn(Date, 'now')

spyOn将需要跟踪的范围和功能作为参数。请注意,我们需要在这里使用字符串作为参数,这并不是真正的重构友好 - 如果我们重命名该函数,很容易错过。

spyOn takes as parameters the scope and the function that requires tracking. Note that we need to use a string as a parameter here, which is not really refactoring-friendly—it’s easy to miss if we rename that function.

A.2.2 玩笑间谍

A.2.2 Jest spies

“间谍”这个词比我们迄今为止在本书中遇到的术语有更有趣的灰色阴影,这就是为什么我不喜欢太多(或根本不)使用它,如果我能帮忙的话它。不幸的是,这个词是 Jest API 的主要部分,所以让我们确保我们理解它。

The word “spy” has a slightly more interesting shade of grey to it than the terms we’ve encountered so far in this book, which is why I don’t like to use it too much (or at all) if I can help it. Unfortunately, this word is a major part of Jest’s API, so let’s make sure we understand it.

Gerard Meszaros 所著的xUnit 测试模式(Addison-Wesley,2007 年)在对间谍的讨论中这样说道:“使用测试替身来捕获被测系统 (SUT) 对另一个组件进行的间接输出调用,以便稍后通过考试。” 间谍与假冒或测试替身之间的唯一区别在于,间谍正在调用下面函数的真实实现,并且它仅跟踪该函数的输入和输出,我们稍后可以通过测试来验证这一点。假冒和测试双打不使用函数的真实实现。

xUnit Test Patterns (Addison-Wesley, 2007), by Gerard Meszaros, says this in its discussion of spies: “Use a Test Double to capture the indirect output calls made to another component by the system under test (SUT) for later verification by the test.” The only difference between a spy and a fake or test double is that a spy is calling the real implementation of the function underneath, and it only tracks the inputs to and outputs from that function, which we can later verify through the test. Fakes and test doubles don’t use the real implementation of a function.

我对间谍的精确定义非常接近:在入口点出口点上使用不可见的跟踪层包装工作单元的行为,而不更改底层功能,以便在测试期间跟踪其输入和输出。

My refined definition of a spy is pretty close: The act of wrapping a unit of work with an invisible tracking layer on the entry points and exit points without changing the underlying functionality, for the purpose of tracking its inputs and outputs during testing.

A.2.3 使用mockImplementation()进行spyOn

A.2.3 spyOn with mockImplementation()

这种间谍固有的“跟踪而不改变功能”行为也解释了为什么仅仅使用spyOn不足以让我们伪造Date.now。它只是用于跟踪,而不是伪造。

This “tracking without changing functionality” behavior that is inherent to spies also explains why just using spyOn won’t be enough for us to fake Date.now. It’s only meant for tracking, not faking.

为了真正伪造Date.now函数并将其转换为,我们将使用令人困惑的命名mockImplementation来替换底层工作单元的功能:

To actually fake the Date.now function and turn it into a stub, we’ll use the confusingly named mockImplementation to replace the underlying unit of work’s functionality:

开玩笑间谍On(日期,“现在”)。mockImplementation (() => /*返回桩时间*/);
jest.spyOn(Date, 'now').mockImplementation(() => /*return stub time*/);

太多“嘲讽”

Too much “mock”

mockImplementation,如果我能够决定一个新名称,我会将其命名为fakeImplementation,因为它可以轻松地用于创建返回数据的桩或验证作为参数发送到其中的数据的模拟。“模拟”这个词在我们的行业中经常被用来表示任何不真实的东西,而这种区别可以帮助我们进行不那么脆弱的测试。名称中的“Mock”立即意味着我们稍后将验证这一点,至少当我查看它时,并考虑到我在本书中如何对待模拟与桩的想法。

If I were in a position to decide on a new name for mockImplementation, I’d name it fakeImplementation, because it can easily be used to create either stubs that return data or mocks that verify the data being sent into them as parameters. The word “mock” is used far too often in our industry to signify anything that isn’t real, when the distinction could help us make less brittle tests. “Mock” in the name immediately implies that this is something we’ll verify against later on, at least when I look at it, and given how I treat the ideas of mocks versus stubs in this book.

Jest 充斥着过度使用“mock”这个词,尤其是在将其 API 与诸如 Sinon.js 之类的隔离框架进行比较时,后者使用的命名并不令人惊讶,并且在不必要的地方避免使用“mock”。

Jest is littered with overuse of the word “mock,” especially when comparing its API to an isolation framework such as Sinon.js, which uses naming that is less surprising and avoids using “mock” where it’s not necessary.

这是spyOnmockImplementation组合在我们的代码中的样子。

Here’s how the spyOn and mockImplementation combo looks in our code.

清单 A.3 用于jest.SpyOn()猴子补丁Date.now()

Listing A.3 Using jest.SpyOn() to monkey-patch Date.now()

描述('v4 findRecentlyRebooted 开玩笑间谍On',()=> {
  afterEach(() => jest.restoreAllMocks() );
 
  test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
    const fromDate = new Date(2000,0,3);
    Date.now = jest.spyOn(Date, 'now') 
      .mockImplementation(() => fromDate.getTime());
 
    const restartTwoDaysEarly = new Date(2000,0,1);
    常量机器 = [
      {lastBootTime:rebootTwoDaysEarly,名称:'忽略'},
      {lastBootTime: fromDate, name: '找到' }];
describe('v4 findRecentlyRebooted with jest spyOn', () => {
  afterEach(() => jest.restoreAllMocks());
 
  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    Date.now = jest.spyOn(Date, 'now')
      .mockImplementation(() => fromDate.getTime());
 
    const rebootTwoDaysEarly = new Date(2000,0,1);
    const machines = [
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }];

可以看到代码中的最后一块拼图就在里面afterEach()。我们使用另一个名为 的函数jestrestoreAllMocks,这是 Jest 将任何已被监视的全局状态重置为其原始实现的方法,周围没有额外的假层。

You can see that the last piece of the puzzle in the code is inside afterEach(). We use another function called jest.restoreAllMocks, which is Jest’s way of resetting any global state that has been spied on to its original implementation with no extra fake layers around it.

请注意,即使我们使用间谍,我们也不会验证该函数是否确实被调用。这样做意味着我们将它用作模拟对象,但我们不是。我们只是将其用作桩。对于 Jest,我们必须通过“间谍”来消除某些东西。

Note that even though we are using a spy, we’re not verifying that the function was actually called. Doing that would mean we’re using it as a mock object, which we are not. We’re merely using it as a stub. With Jest, we have to go through a “spy” to stub stuff out.

我之前列出的所有优点和缺点仍然适用于此。我更喜欢在有意义时使用参数,而不是使用全局函数或变量。

All of the advantages and disadvantages I’ve listed before still apply here. I prefer using parameters when it makes sense, instead of using global functions or variables.

A.3 使用 Jest 忽略整个模块很简单

A.3 Ignoring a whole module with Jest is simple

在本附录中提到的所有技术中,这是最安全的,因为它不涉及被测单元的内部工作。它只是广泛地忽略了事情。

Of all the techniques mentioned in this appendix, this is the safest because it does not deal with the internal workings of the unit under test. It just ignores things in a broad manner.

如果我们在测试期间根本不关心该模块,并且我们只想让它脱离我们的场景而不从中获取任何虚假数据,那么对jest.mock('module path')测试文件顶部的简单调用将做得很好,没有太多大惊小怪。

If we don’t care about the module at all during our tests, and we just want to get it out of the way of our scenario without getting any fake data back from it, a simple call to jest.mock('module path') at the top of the test file will do just fine, without too much fuss.

如果您想在每个测试中从假模块模拟自定义数据,那么下一节会有所帮助,这会让我们经历更多的麻烦。

The next section helps if you want to simulate custom data in each test from a fake module, which makes us go through more hoops.

A.4 每次测试中伪造模块行为

A.4 Faking module behavior in each test

伪造模块基本上意味着伪造全局对象import每当或require第一次被测试代码使用时都会加载它。根据我们使用的测试框架,模块可能会在内部缓存或通过标准 Node.jsrequire.cache机制缓存。由于这只发生一次,当我们的测试导入被测系统时,当我们试图在同一文件中为不同的测试伪造不同的行为或数据时,我们会遇到一些问题。

Faking a module basically means faking a global object that gets loaded whenever import or require is used for the first time by the code under test. Depending on the test framework we’re using, the module might be cached internally or through the standard Node.js require.cache mechanism. Since this only happens once, when our test imports the system under test, we have a bit of an issue when we’re trying to fake different behavior or data for different tests in the same file.

为了伪造我们的假模块的自定义行为,我们需要在测试中注意以下事项:从内存中清理所需的模块,替换它,重新需要它,并让被测试的代码使用新模块而不是使用新模块。通过要求我们再次测试我们的代码来恢复原来的状态。那是相当多了。我将此模式称为 Clear-Fake-Require-Act (CFRA):

To fake custom behavior for our fake module, we need to take care of the following in our tests: clean up the required module from memory, replace it, re-require it, and get the code under test to use the new module instead of the original one by requiring our code under test again. That’s quite a bit. I call this pattern Clear-Fake-Require-Act (CFRA):

  1. 清除- 在每次测试之前,清除测试运行程序内存中的所有缓存或所需模块。

  2. Clear—Before each test, clear all the cached or required modules in the test runner’s memory.

  3. 在测试的安排部分:

    1. Fakerequire — 伪造测试代码调用的操作所需的模块。

    2. Require——在调用被测试的代码之前需要它。

  4. During the arrange part of the test:

    1. Fake—Fake the module that will be required by the require action invoked by the test code.

    2. Require—Require the code under test just before invoking it.

  5. Act——调用入口点。

  6. Act—Invoke the entry point.

如果我们忘记了这些步骤中的任何一个,或者以错误的顺序执行它们,或者不在测试生命周期的正确时间点执行它们,那么当我们执行测试时就会出现很多问号,并且事情似乎不正确。 。更糟糕的是,它们有时可能会正常工作。不寒而栗。

If we forget any of these steps, or perform them in the wrong order, or not at the right point in the test’s life cycle, there’ll be a lot of question marks when we execute the test and things seem not to be faking correctly. Worse, they might sometimes work correctly. Shudder.

让我们看一个真实的例子,从下面的代码开始。

Let’s look at a real example, starting with the following code.

清单 A.4 带有依赖项的测试代码

Listing A.4 Code under test with a dependency

const { getAllMachines } = require('./my-data-module');     
 
const daysFrom = (从, 到) => {
  const ms = from.getTime() - new Date(to).getTime();
  常量差异 = (毫秒 / 1000) / 60 / 60 / 24; // 秒 * 分钟 * 小时
  控制台.log(差异);
  返回差异;
};
 
const findRecentlyRebooted = (maxDays, fromDate) => {
  const 机器 = getAllMachines();
  返回machines.filter(机器=> {
    const daysDiff = daysFrom(fromDate, machine.lastBootTime);
    console.log(`${daysDiff} vs ${maxDays}`);
    返回 daysDiff < maxDays;
  });
};
const { getAllMachines } = require('./my-data-module');     
 
const daysFrom = (from, to) => {
  const ms = from.getTime() - new Date(to).getTime();
  const diff = (ms / 1000) / 60 / 60 / 24; // secs * min * hrs
  console.log(diff);
  return diff;
};
 
const findRecentlyRebooted = (maxDays, fromDate) => {
  const machines = getAllMachines();
  return machines.filter(machine => {
    const daysDiff = daysFrom(fromDate, machine.lastBootTime);
    console.log(`${daysDiff} vs ${maxDays}`);
    return daysDiff < maxDays;
  });
};

对造假的依赖

The dependency to fake

第一行包含我们需要在测试中打破的依赖关系。它是getAllMachines从 解构出来的函数my-data-module。因为我们使用的是与其父模块分离的函数,所以我们不能只在父模块上伪造函数并期望我们的测试能够通过。我们必须获得解构函数才能在解构过程中获得假函数,这就是棘手的部分。

The first line contains the dependency we need to break in our test. It’s the getAllMachines function, being destructured from my-data-module. Because we are using the function detached from its parent module, we can’t just fake functions on the parent module and expect our tests to pass. We have to get the destructured function to get a fake function during the destructuring process, and that’s where the tricky part comes in.

A.4.1 使用 vanilla require.cache 桩模块

A.4.1 Stubbing a module with vanilla require.cache

在我们使用 Jest 和其他框架来伪造整个模块之前,让我们看看如何实现这种效果并探索各个框架中发生的情况。

Before we use Jest and other frameworks to fake a whole module, let’s see how we can achieve this effect and explore what’s going on in the various frameworks.

您可以直接使用 CFRA 模式,而无需使用任何框架require.cache

You can use the CFRA pattern without using any framework by using require.cache directly.

清单 A.5 桩require.cache

Listing A.5 Stubbing with require.cache

const 断言 = require('断言');
const { 检查 } = require('./custom-test-framework');

const dataModulePath = require.resolve('../my-data-module');

const fakeDataFromModule = fakeData => {
  删除 require.cache[dataModulePath];                                 
  require.cache[dataModulePath] = {                                     
    id:数据模块路径,
    文件名:数据模块路径,
    已加载:真实,
    出口:{
      getAllMachines: () => fakeData
    }
  };
  需要(数据模块路径);
};

const requireAndCall _ findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');      
  return findRecentlyRebooted(maxDays, fromDate);                      
};


check('给定 2 台机器中的 1 台低于阈值,找到它', () => {
  const restartTwoDaysEarly = new Date(2000,0,1);
  const fromDate = new Date(2000,0,3);
  fakeDataFromModule([ 
    {lastBootTime:rebootTwoDaysEarly,name:'ignored'}, 
    {lastBootTime:fromDate,name:'found'} 
  ]);

  const 结果 = requireAndCall _ findRecentlyRebooted(1, fromDate) ;
  断言(结果.length === 1);
  断言(结果[0].name.includes('found'));
});
const assert = require('assert');
const { check } = require('./custom-test-framework');

const dataModulePath = require.resolve('../my-data-module');

const fakeDataFromModule = fakeData => {
  delete require.cache[dataModulePath];                                
  require.cache[dataModulePath] = {                                    
    id: dataModulePath,
    filename: dataModulePath,
    loaded: true,
    exports: {
      getAllMachines: () => fakeData
    }
  };
  require(dataModulePath);
};

const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');     
  return findRecentlyRebooted(maxDays, fromDate);                      
};


check('given 1 of 2 machines under the threshold, it is found', () => {
  const rebootTwoDaysEarly = new Date(2000,0,1);
  const fromDate = new Date(2000,0,3);
  fakeDataFromModule([
    { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
    { lastBootTime: fromDate, name: 'found' }
  ]);

  const result = requireAndCall_findRecentlyRebooted(1, fromDate);
  assert(result.length === 1);
  assert(result[0].name.includes('found'));
});

清除

Clear

假货

Fake

要求

Require

行动

Act

不幸的是,这段代码不适用于 Jest,因为 Jest 忽略require.cache并在内部实现了自己的缓存算法。要执行此测试,请直接通过 Node.js 命令行运行它。您会看到我已经实现了自己的小check()功能,因此我不使用 Jest 的 API。当使用 Jasmine 等框架时,此测试可以正常工作。

Unfortunately, this code will not work with Jest, because Jest ignores require.cache and implements its own caching algorithm internally. To execute this test, run it directly through the Node.js command line. You’ll see that I’ve implemented my own little check() function, so that I don’t use Jest’s API. This test will work just fine when using a framework such as Jasmine.

还记得我们测试的代码中的这一行吗?

Remember this line in our code under test?

const { getAllMachines } = require('./my-data-module');
const { getAllMachines } = require('./my-data-module'); 

每次我们想要返回一个假值时,我们的测试都需要执行这种解构。这意味着我们需要从测试代码执行被测单元的 require 或导入,不是在文件顶部,而是在测试执行中间的某个位置。您可以在清单 A.5 的以下部分中看到这种情况发生的位置:

Our tests need to execute this destructuring every time we want to return a fake value. That means we’ll need to execute a require or import of the unit under test from our test code, not at the top of the file, but somewhere in the middle of our test execution. You can see where this happens in the following part of listing A.5:

const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  返回 findRecentlyRebooted(maxDays, fromDate);
};
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  return findRecentlyRebooted(maxDays, fromDate);
};

正是由于这种解构代码模式,模块不仅仅是具有属性的对象,可以使用普通的猴子修补技术。我们需要跨越更多的障碍。

It is because of this destructuring code pattern that modules are not just objects with properties, for which normal monkey-patching techniques can be used. We need to jump through more hoops.

让我们将四个 CFRA 步骤映射到清单 A.5 中的代码:

Let’s map the four CFRA steps to the code in listing A.5:

  • 清除fakeDataFromModule——这是测试期间调用的函数的一部分。

  • Clear—This is part of the fakeDataFromModule function, which is invoked during the test.

  • Fake——我们告诉require.cache的字典条目返回一个自定义对象,该对象似乎代表了一个模块的样子,但它有一个返回 的自定义实现fakeData

  • Fake—we are telling require.cache’s dictionary entry to return a custom object that seems to represent what a module looks like, but which has a custom implementation that returns fakeData.

  • Require——我们需要被测试的代码作为requireAndCall_ findRecentlyRebooted()函数的一部分,该函数在测试期间被调用。

  • Require—We are requiring the code under test as part of the requireAndCall_ findRecentlyRebooted() function, which is invoked during the test.

  • ACTrequireAndCall_findRecentlyRebooted() —这是测试调用的同一函数的一部分。

  • ACT—This is part of the same requireAndCall_findRecentlyRebooted() function that is invoked by the test.

请注意,我们不用beforeEach()于此测试。我们直接从测试中进行所有操作,因为每个测试都会从模块中伪造自己的数据。

Notice that we do not use beforeEach() for this test. We are doing everything directly from the test, because each test will fake its own data from the module.

A.4.2 使用 Jest 桩自定义模块数据很复杂

A.4.2 Stubbing custom module data with Jest is complicated

我们已经看到了桩自定义模块数据的“普通”方式。不过,如果您使用 Jest,通常不会这样做。Jest 包含几个令人困惑且命名非常接近的函数,用于处理清除和伪造模块,包括mockdoMockgenMockFromModuleresetAllMocksclearAllMocksrestoreAllMocksresetModules。耶!

We’ve seen the “vanilla” way of stubbing custom module data. That’s not usually how you’d do it if you’re using Jest, though. Jest contains several confusingly and very closely named functions that deal with clearing and faking modules, including mock, doMock, genMockFromModule, resetAllMocks, clearAllMocks, restoreAllMocks, resetModules and more. Yay!

我在这里推荐的代码在可读性和可维护性方面感觉是所有 Jest API 中最干净、最简单的。我确实在 GitHub 存储库(https://github.com/royosherove/aout3-samples)和“other-variations”文件夹(http://mng.bz/Jddo )中介绍了它的其他变体。

The code I’ll recommend here feels the cleanest and simplest of all of Jest’s APIs in terms of readability and maintainability. I do cover other variations on it in the GitHub repository at https://github.com/royosherove/aout3-samples and under the “other-variations” folder at http://mng.bz/Jddo.

这是使用 Jest 伪造模块的常见模式:

This is the common pattern for faking a module with Jest:

  1. 需要您想要在自己的测试中伪造的模块。

  2. Require the module you’d like to fake in your own tests.

  3. 用 删除测试上方的模块jest.mock(modulename)

  4. Stub out the module above the tests with jest.mock(modulename).

[modulename].function.mockImplementation()在每个测试中,告诉 Jest 使用或覆盖该模块中函数之一的行为mockImplementationOnce()

In each test, tell Jest to override the behavior of one of the functions in that module by using [modulename].function.mockImplementation() or mockImplementationOnce().

下面是它可能的样子。

The following is what it might look like.

清单 A.6 使用 Jest 桩模块

Listing A.6 Stubbing a module with Jest

const dataModule = require('../my-data-module');
const { findRecentlyRebooted } = require('../machine-scanner4');
 
const fakeDataFromModule = (fakeData) => 
    dataModule.getAllMachines.mockImplementation(() => fakeData);
 
jest.mock('../my-data-module');
 
描述('findRecentlyRebooted', () => {
  beforeEach(jest.resetAllMocks); //<- 最干净的方式
 
  test('没有机器,返回空结果', () => {
    fakeDataFromModule([]);
    const someDate = new Date(2000,0,1);
 
    const 结果 = findRecentlyRebooted(0, someDate);
 
    期望(结果.长度).toBe(0);
  });
 
  test('给定 2 台机器中的 1 台低于阈值,找到它', () => {
    const fromDate = new Date(2000,0,3);
    const restartTwoDaysEarly = new Date(2000,0,1);
    fakeDataFromModule([ 
      {lastBootTime:rebootTwoDaysEarly,name:'ignored'}, 
      {lastBootTime:fromDate,name:'found'} 
    ]);
    const 结果 = findRecentlyRebooted(1, fromDate);
    期望(结果.长度).toBe(1);
    Expect(result[0].name).toContain('found');
  });
const dataModule = require('../my-data-module');
const { findRecentlyRebooted } = require('../machine-scanner4');
 
const fakeDataFromModule = (fakeData) =>
    dataModule.getAllMachines.mockImplementation(() => fakeData);
 
jest.mock('../my-data-module');
 
describe('findRecentlyRebooted', () => {
  beforeEach(jest.resetAllMocks); //<- the cleanest way
 
  test('given no machines, returns empty results', () => {
    fakeDataFromModule([]);
    const someDate = new Date(2000,0,1);
 
    const result = findRecentlyRebooted(0, someDate);
 
    expect(result.length).toBe(0);
  });
 
  test('given 1 of 2 machines under threshold, it is found', () => {
    const fromDate = new Date(2000,0,3);
    const rebootTwoDaysEarly = new Date(2000,0,1);
    fakeDataFromModule([
      { lastBootTime: rebootTwoDaysEarly, name: 'ignored' },
      { lastBootTime: fromDate, name: 'found' }
    ]);
    const result = findRecentlyRebooted(1, fromDate);
    expect(result.length).toBe(1);
    expect(result[0].name).toContain('found');
  });

以下是您如何使用 Jest 处理 CFRA 的各个部分。

Here’s how you can approach each part of CFRA with Jest.

清除

Clear

jest.resetAllMocks

jest.resetAllMocks

伪造的

Fake

jest.mock()+[fake].mockImplementation()

jest.mock()+[fake].mockImplementation()

要求

Require

经常出现在文件的顶部

Regularly at the top of the file

行为

Act

经常

Regularly

jest.mock方法jest.resetAllMocks都是关于伪造模块并将伪造的实现重置为空的。请注意,该模块在 . 之后仍然是假的resetAllMocks。仅其行为被重置为默认的假实现。调用它而不告诉它返回什么会产生奇怪的错误。

The jest.mock and jest.resetAllMocks methods are all about faking the module and resetting the fake implementation to an empty one. Note that the module is still fake after resetAllMocks. Only its behavior is reset to the default fake implementation. Calling it without telling it what to return will yield weird errors.

使用该FromModule方法,我们用一个在每个测试中返回硬编码值的函数替换默认实现。

With the FromModule method, we replace the default implementation with a function that returns our hardcoded values in each test.

我们本来可以使用mockImplementationOnce()模拟来代替fakeDataFromModule()方法,但我发现这会创建非常脆弱的测试。对于桩,我们通常不应该关心它们返回假值的次数。如果我们确实关心它们被调用了多少次,我们会将它们用作模拟对象,这就是第 4 章的主题。

We could have used mockImplementationOnce() to do mocking, instead of the fakeDataFromModule() method, but I find that this can create very brittle tests. With stubs, we normally shouldn’t care how many times they return the fake values. If we did care how many times they were called, we would use them as mock objects, and that’s the subject of chapter 4.

A.4.3 避免 Jest 的手动模拟

A.4.3 Avoid Jest’s manual mocks

Jest 包含手动模拟的想法,但如果可以的话就不要使用它们。此技术要求您在测试中放置一个特殊的 __mocks__ 文件夹,其中包含硬编码的假模块代码,并具有基于模块名称的命名约定。这可行,但是当你想控制假数据时,可维护性成本太高。可读性成本也太高,因为它将滚动疲劳增加到不必要的水平,需要我们在多个文件之间切换才能理解测试。您可以在 Jest 文档中阅读有关手动模拟的更多信息:https://jestjs.io/docs/en/manual-mocks.xhtml

Jest contains the idea of manual mocks, but don’t use them if you can help it. This technique requires you to put a special __mocks__ folder in your tests that contain hardcoded fake module code, with a naming convention based on the module’s name. This will work, but the maintainability costs are too high when you want to control the fake data. The readability costs are too high as well, as it increases scroll fatigue to an unneeded level, requiring us to switch between multiple files to understand a test. You can read more about manual mocks in the Jest documentation: https://jestjs.io/docs/en/manual-mocks.xhtml.

A.4.4 使用Sinon.js 对模块进行桩

A.4.4 Stubbing a module with Sinon.js

为了进行比较,您可以看到 CFRA 的模式在其他框架中重复,下面是使用 Sinon.js(一个专门用于创建桩的框架)进行相同测试的实现。

For comparison, and so that you can see that the pattern of CFRA repeats in other frameworks, here’s an implementation of the same test with Sinon.js—a framework dedicated to creating stubs.

清单 A.7 使用Sinon.js 对模块进行桩

Listing A.7 Stubbing a module with Sinon.js

const sinon = require('sinon');
让数据模块;
const fakeDataFromModule = fakeData => {
 sinon.stub(dataModule, 'getAllMachines') 
    .returns(fakeData);
};
 
const resetAndRequireModules = () => { 
  jest.resetModules(); 
  dataModule = require('../my-data-module'); 
};
 
const requireAndCall_findRecentlyRebooted = (maxDays, someDate) => { 
  const { findRecentlyRecentlyRebooted } = require('../machine-scanner4'); 
  返回 findRecentlyRebooted(maxDays, someDate); 
};
 
描述('4 sinon沙箱findRecentlyRebooted', () => {
 beforeEach(resetAndRequireModules);
 
  test('没有机器,返回空结果', () => {
    const someDate = new Date('01 01 2000');
    fakeDataFromModule([]);
 
    const result = requireAndCall_findRecentlyRebooted (2, someDate);
 
    期望(结果.长度).toBe(0);
  });
const sinon = require('sinon');
let dataModule;
const fakeDataFromModule = fakeData => {
  sinon.stub(dataModule, 'getAllMachines')
    .returns(fakeData);
};
 
const resetAndRequireModules = () => {
  jest.resetModules();
  dataModule = require('../my-data-module');
};
 
const requireAndCall_findRecentlyRebooted = (maxDays, someDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  return findRecentlyRebooted(maxDays, someDate);
};
 
describe('4  sinon sandbox findRecentlyRebooted', () => {
  beforeEach(resetAndRequireModules);
 
  test('given no machines, returns empty results', () => {
    const someDate = new Date('01 01 2000');
    fakeDataFromModule([]);
 
    const result = requireAndCall_findRecentlyRebooted(2, someDate);
 
    expect(result.length).toBe(0);
  });

让我们用Sinon来映射相关部分。

Let’s map the relevant parts with Sinon.

清除

Clear

每次测试前:

Before each test:

jest.resetModules + re-require fake module

jest.resetModules + re-require fake module

伪造的

Fake

每次测试前:

Before each test:

sinon.stub(module,'function')

sinon.stub(module,'function')

.returns(fakeData)

.returns(fakeData)

需要(被测模块)

Require (module under test)

在调用入口点之前

Before invoking the entry point

行为

Act

重新要求被测模块后

After re-requiring the module under test

A.4.5 使用 testdouble 对模块进行桩

A.4.5 Stubbing a module with testdouble

测试替身是另一个隔离可以很容易地用来解决问题的框架。由于在之前的测试中已经完成了重构,因此代码更改很小。

Testdouble is another isolation framework that can easily be used to stub things out. Due to the refactoring already done in previous tests, the code changes are minimal.

清单 A.8 使用 testdouble 对模块进行桩

Listing A.8 Stubbing a module with testdouble

让td;
 
const ResetAndRequireModules = () => {
  jest.resetModules();
 td = require('testdouble'); 
 require('testdouble-jest')(td, 玩笑);
};
const fakeDataFromModule = fakeData => {
 td.replace('../my-data-module', { 
    getAllMachines: () => fakeData 
 });
};
 
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  返回 findRecentlyRebooted(maxDays, fromDate);
};
let td;
 
const resetAndRequireModules = () => {
  jest.resetModules();
  td = require('testdouble');
  require('testdouble-jest')(td, jest);
};
const fakeDataFromModule = fakeData => {
  td.replace('../my-data-module', {
    getAllMachines: () => fakeData
  });
};
 
const requireAndCall_findRecentlyRebooted = (maxDays, fromDate) => {
  const { findRecentlyRebooted } = require('../machine-scanner4');
  return findRecentlyRebooted(maxDays, fromDate);
};

以下是 testdouble 的重要部分。

Here are the important parts with testdouble.

清除

Clear

每次测试前:

Before each test:

jest.resetModules + require('testdouble');

jest.resetModules + require('testdouble');

require('testdouble-jest')

require('testdouble-jest')

(td, jest);

(td, jest);

伪造的

Fake

每次测试前:

Before each test:

Td.replace(module, fake object)

Td.replace(module, fake object)

需要(被测模块)

Require (module under test)

在调用入口点之前

Before invoking the entry point

行为

Act

重新要求被测模块后

After re-requiring the module under test

测试实现与Sinon示例完全相同。我们还使用testdouble-jest,因为它连接到 Jest 模块替换设施。如果我们使用不同的测试框架,则不需要这样做。

The test implementation is exactly the same as with the Sinon example. We’re also using testdouble-jest, as it connects to the Jest module replacement facility. This is not needed if we’re using a different test framework.

这些技术起作用,但我建议远离它们,除非绝对没有其他办法。几乎总是有其他方法,您可以在第 3 章中看到其中的许多方法。

These techniques will work, but I recommend staying away from them unless there’s absolutely no other way. There is almost always another way, and you can see many of those in chapter 3.

指数

index

符号

Symbols

<+> 符号 154

<+> sign 154

A

A

AAA(安排-执行-断言)模式 38

AAA (Arrange-Act-Assert) pattern 38

add() 函数 142

add() function 142

addDefaultUser() 函数 173

addDefaultUser() function 173

加法器类 141

Adder class 141

addRule() 函数 44

addRule() function 44

采用的流程 217

adopted process 217

隔离框架的优点和陷阱 117120

advantages and traps of isolation frameworks 117120

过度指定测试 119

overspecifying tests 119

验证错误的事情118

verifying wrong things 118

提倡的流程 217

advocated process 217

afterAll() 函数 45

afterAll() function 45

afterEach() 函数 45 , 173 , 240

afterEach() function 45, 173, 240

test() 函数的别名 42

alias to test() function 42

反模式

antipatterns

测试等级199

at test level 199

仅低级测试反模式 202

low-level-only test antipattern 202

测试级、仅限端到端反模式 199

test-level, end-to-end-only antipattern 199

API(应用程序编程接口)测试 198

API (application programming interface) tests 198

Arg.is() 函数 113

Arg.is() function 113

Array.prototype.every() 方法 4041

Array.prototype.every() method 4041

描述()函数 4041

describe() function 4041

verifyPassword() 函数 4041

verifyPassword() function 4041

assertEquals() 函数 14

assertEquals() function 14

断言库 33

assertion library 33

断言轮盘赌 89

assertion roulette 89

断言模块14

assert module 14

异步/等待 124125

async/await 124125

async/await 函数结构 129

async/await function structures 129

异步代码,单元测试 121145

asynchronous code, unit testing 121145

异步/等待机制 122

async/await mechanism 122

异步数据获取 122

asynchronous data fetching 122

回调方法 124125

callback approach 124125

处理 124125

dealing with 124125

回调机制122

callback mechanism 122

常见事件 141142

common events 141142

点击事件 142144

click events 142144

处理事件发射器 141142

dealing with event emitters 141142

处理定时器 138139

dealing with timers 138139

DOM 测试库 144145

DOM testing library 144145

提取适配器模式 125

Extract Adapter pattern 125

功能适配器 135136

functional adapter 135136

模块化适配器 133134

modular adapter 133134

基于面向对象接口的适配器 136138

object-oriented-interface-based adapter 136138

提取入口点模式 125

Extract Entry Point pattern 125

提取入口点126

extracting entry points 126

等待 129131

with await 129131

使代码单元测试友好 125 , 127129

making code unit-test friendly 125, 127129

定时器 138139

timers 138139

开玩笑 139141

faking with Jest 139141

用猴子补丁清除 138139

stubbing out with monkey-patching 138139

单元测试友好的代码 125138

unit-test-friendly code 125138

提取适配器模式 131133

Extract Adapter pattern 131133

功能适配器 135136

functional adapter 135136

单元测试 121146

unit testing 121146

集成测试面临的挑战 125

challenges with integration tests 125

DOM 测试库 144145

DOM testing library 144145

另请参阅单元测试友好的代码

See also unit-test-friendly code

异步处理,通过线性、同步测试进行仿真 16

asynchronous processing, emulating with linear, synchronous tests 16

避免设置方法 175176

avoiding setup methods 175176

B

BDD(行为驱动开发)4243

BDD (behavior-driven development) 4243

贝克,肯特 25

Beck, Kent 25

beforeAll() 函数 45

beforeAll() function 45

beforeEach() 函数 4547 , 5051 , 240

beforeEach() function 4547, 5051, 240

47 – 49概述

overview of 4749

滚动疲劳和 4749

scroll fatigue and 4749

阻滞剂 215

blockers 215

自下而上的实施 217

bottom-up implementation 217

有 bug 的测试,一旦发现 151 该怎么办

buggy tests, what to do once you’ve found 151

bug,生产代码中的真正 bug 151

bugs, real bug in production code 151

建造耳语者201

build whisperers 201

业务目标和指标

business goals and metrics

分成组 221222

breaking up into groups 221222

领先指标 221

leading indicators 221

C

C

计算器示例,工厂方法 4950

calculator example, factory methods 4950

回调机制122

callback mechanism 122

catch() 期望 55

catch() expectation 55

CFRA(清除假货要求法案)模式 243

CFRA (Clear-Fake-Require-Act) pattern 243

变革推动者

change agents

阻滞剂 215

blockers 215

考虑项目可行性 216

considering project feasibility 216

令人信服的内部人士214

convincing insiders 214

更换管理层

change management

确定起点 215

identifying starting points 215

取得明显进展 219220

making progress visible 219220

变化

changes

同事的态度,为棘手的问题做好准备 214

colleagues’ attitudes, being prepared for tough questions 214

因考试失败而被迫 166

forced by failing tests 166

第152章

in functionality, avoiding or preventing test failure due to 152

在其他测试中 169

in other tests 169

测试文化并使用代码和测试评审作为教学工具 216

testing culture and using code and test reviews as teaching tools 216

check() 函数 14 , 245

check() function 14, 245

干净的代码(马丁)26

Clean Code (Martin) 26

清除AllMocks()函数246

clearAllMocks() function 246

点击事件 142144

click events 142144

代码审查,用作教学工具 216

code reviews, using as teaching tools 216

CodeScene,使用 236 调查生产代码

CodeScene, investigating production code with 236

未经测试的代码 228

code without tests 228

命令106

command 106

命令/查询分离 9 , 106

command/query separation 9, 106

常见测试类型和级别,E2E/UI系统测试 199

common test types and levels, E2E/UI system tests 199

复杂的接口,例如9899

complicated interfaces, example of 9899

组件测试,概述 196

component tests, overview of 196

担忧,测试多个退出点 158160

concerns, testing multiple exit points 158160

信心202

confidence 202

约束测试顺序 170173

constrained test order 170173

构造函数 7374

constructor functions 7374

构造函数注入 7476

constructor injection 7476

持续测试 33

continuous testing 33

控制 68

control 68

控制流代码22

control flow code 22

_.curry() 函数 93

_.curry() function 93

柯里化,不使用 93

currying, not using 93

CUT(被测组件、类或代码)6

CUT (component, class, or code under test) 6

圈复杂度232

cyclomatic complexity 232

D

D

数据库,替换为桩 16

database, replacing with stubs 16

Date.now 全球 239240

Date.now global 239240

调试()函数 88

debug() function 88

调试器,需要测试代码是否有效 229

debuggers, need for tests if code works 229

解耦,工厂函数解耦被测对象的创建 168169

decoupling, factory functions decouple creation of object under test 168169

传递阻塞测试 208

delivery-blocking tests 208

输送管道

delivery pipelines

交付与发现管道 208

delivery vs. discovery pipelines 208

测试层并行化 210211

test layer parallelization 210211

依赖项 62 , 68

dependencies 62, 68

破坏桩、面向对象注入技术 74

breaking with stubs, object-oriented injection techniques 74

62 – 64的类型

types of 6264

依赖对象 71

dependencies object 71

依赖变量 72

dependencies variable 72

依赖注入(DI)138

dependency injection (DI) 138

用桩断开 61

breaking with stubs 61

桩设计方法 66

design approaches to stubbing 66

功能注入技术 6970

functional injection techniques 6970

模块化注射技术 7073

modular injection techniques 7073

面向对象注入技术 7981

object-oriented injection techniques 7981

依赖注入 (DI) 容器 77

Dependency Injection (DI) containers 77

依赖倒置 67

Dependency Inversion 67

描述()块 41 , 46

describe() block 41, 46

描述 () 函数 30 , 4041 , 52

describe() function 30, 4041, 52

描述驱动语法 42

describe-driven syntax 42

描述结构 188

describe structure 188

destructed() 函数 244

destructured() function 244

区分模拟和桩 8889

differentiating between mocks and stubs 8889

E2E(端到端)测试的收益递减 199

diminishing returns from E2E (end-to-end) tests 199

直接依赖,抽象掉 109

direct dependencies, abstracting away 109

发现管道与交付管道 208

discovery pipelines, vs. delivery pipelines 208

文档.加载事件 144

document.load event 144

DOM(文档对象模型)测试库 144145

DOM (Document Object Model) testing library 144145

doMock() 函数 246

doMock() function 246

完成()回调124

done() callback 124

done() 函数 124 , 129 , 142

done() function 124, 129, 142

DRY(不要重复自己)原则 175

DRY (don’t repeat yourself) principle 175

鸭子打字 79

duck typing 79

虚拟数据 67

dummy data 67

虚拟值 67

dummy value 67

动态模拟和桩 104 , 109110

dynamic mocks and stubs 104, 109110

功能性 109110

functional 109110

动态桩 114116

dynamic stubbing 114116

E

E2E(端到端)测试 198199

E2E (end-to-end) tests 198199

完全避免202

avoiding completely 202

打造耳语者201

build whisperer 201

从 199 开始收益递减

diminishing returns from 199

E2E/UI隔离测试 198

E2E/UI isolated tests 198

边缘情况 205

edge cases 205

仅端到端反模式 199201

end-to-end-only antipattern 199201

避免构建耳语者 201

avoiding build whisperers 201

完全避免E2E测试202

avoiding E2E tests completely 202

扔过墙心态201

throw it over the wall mentality 201

当它发生时 202

when it happens 202

入口点 610 , 241

entry points 610, 241

提取,等待 129131

extracting, with await 129131

错误数组 46

errors array 46

事件驱动编程 142144

event-driven programming 142144

事件发射器 141142

event emitters 141142

准确:假标志 145

exact:false flag 145

异常,检查预期抛出的错误 5556

exceptions, checking for expected thrown errors 5556

执行笑话 3133

executing Jest 3133

出口点 6 , 911 , 158160 , 241

exit points 6, 911, 158160, 241

不同的退出点,不同的技术 12

different exit points, different techniques 12

预计 33

expect 33

期望()函数30

expect() function 30

55.expect().toThrowError()方法

expect().toThrowError() method 55

实验

experiments

作为开门器 218

as door openers 218

指标和 218

metrics and 218

提取适配器模式 131133

Extract Adapter pattern 131133

功能适配器 135136

functional adapter 135136

模块化适配器 133134

modular adapter 133134

基于面向对象接口的适配器 136138

object-oriented-interface-based adapter 136138

提取适配器模式 125

extracting adapter pattern 125

提取入口点模式 125

extracting entry point pattern 125

F

F

工厂功能 168169

factory functions 168169

工厂方法 4950 , 168

factory methods 4950, 168

将 beforeEach() 完全替换为 5051

replacing beforeEach() completely with 5051

测试失败

failed tests

越野车测试 151

buggy tests 151

测试失败的原因 152

reasons tests fail 152

假241

fake 241

FakeComplicatedLogger 类 100

FakeComplicatedLogger class 100

fakeDataFromModule() 方法 245 , 247

fakeDataFromModule() method 245, 247

假实现 242

fakeImplementation 242

FakeLogger 类 96 , 98 , 168

FakeLogger class 96, 98, 168

假模块行为

fake module behavior

避免 Jest 的手动模拟 247

avoiding Jest’s manual mocks 247

使用 Sinon.js 桩模块 247

stubbing modules with Sinon.js 247

假模块,动态 106108

fake modules, dynamically 106108

假对象和函数

fake objects and functions

每次测试中伪造模块行为 248249

faking module behavior in each test 248249

使用 testdouble 进行桩 248249

stubbing with testdouble 248249

假时间提供者 78

FakeTimeProvider 78

假(xUnit 测试模式,Meszaros)64

fake (xUnit Test Patterns, Meszaros) 64

假故障 166

false failures 166

功能切换 166

feature toggles 166

findFailedRules() 函数 178179

findFailedRules() function 178179

findResultFor() 函数 181

findResultFor() function 181

第一个单元测试,设置测试类别 5657

first unit test, setting test categories 5657

片状测试 153 , 161

flaky tests 153, 161

处理 163

dealing with 163

混合单元测试和集成测试 158

mixing unit tests and integration tests 158

防止更高级别测试中的不稳定 163

preventing flakiness in higher-level tests 163

文件夹,为 Jest 2930做准备

folders, preparing for Jest 2930

史蒂夫·弗里曼 26

Freeman, Steve 26

完全控制 15

full control 15

功能动态模拟和桩 109110

functional dynamic mocks and stubs 109110

功能注入技术 69

functional injection techniques 69

注入功能 6970

injecting functions 6970

部分应用 70

partial application 70

功能、变更、过时测试 152

functionality, change in, out of date tests 152

功能风格

functional style

高阶函数 93

higher-order functions 93

模拟、柯里化 9293

mocks, currying 9293

功能

functions

注射 6970

injecting 6970

注射代替物体 7679

injecting instead of objects 7679

it() 函数 42

it() function 42

猴子修补 238

monkey-patching 238

每次测试中伪造模块行为 243244 , 247249

faking module behavior in each test 243244, 247249

全局变量和可能的问题 239241

globals and possible issues 239241

使用 Jest 242 忽略整个模块

ignoring whole modules with Jest 242

设置 175176

setup 175176

测试()52

test() 52

验证密码 4345

verifyPassword 4345

G

G

genMockFromModule() 函数 246

genMockFromModule() function 246

getDay() 函数 72 , 76

getDay() function 72, 76

getLogLevel() 函数 107

getLogLevel() function 107

全局变量

globals

开玩笑的间谍 241

Jest spies 241

猴子修补函数和模块 239241

monkey-patching functions and modules 239241

具体目标 220

goals, specific 220

必备测试 208

good-to-know tests 208

以测试为指导,不断发展面向对象的软件(Freeman 和 Pryce)26

Growing Object-Oriented Software, Guided by Tests (Freeman and Pryce) 26

游击实施217

guerrilla implementation 217

H

H

幸福之路205

happy path 205

硬优先策略,234 的优缺点

hard-first strategy, pros and cons of 234

六角形建筑 109

hexagonal architecture 109

高阶函数 93

higher-order functions 93

高级测试,断开低级测试和204

high-level tests, disconnected low-level and 204

I

IComplicatedLogger接口 99

IComplicatedLogger interface 99

如果/否则 155

if/else 155

ILogger接口 96 , 98 , 166

ILogger interface 96, 98, 166

传入依赖项 62

incoming dependencies 62

指标

indicators

分成组 221222

breaking up into groups 221222

滞后指标 220

lagging indicators 220

INetworkAdapter 参数 136

INetworkAdapter parameter 136

信息功能 88 , 96 , 107

info function 88, 96, 107

信息方法 88

info method 88

注入()函数72

inject() function 72

注入日期()函数72

injectDate() function 72

注入依赖性()函数91

injectDependencies() function 91

注入函数 72

inject function 72

注射 68

injections 68

模块化注入,示例 92

modular-style injection, example of 92

内部人士,令人信服214

insiders, convincing 214

单元测试的集成

integration of unit testing

进入组织,说服管理层 217

into organization, convincing management 217

指标和实验 218

metrics and experiments 218

集成测试

integration testing

异步/等待 124125

async/await 124125

挑战 125

challenges with 125

单元测试,融入组织 228

unit testing, integrating into organization 228

集成测试 123 , 197

integration tests 123, 197

片状,与单元测试混合 158

flaky, mixing with unit tests 158

交互测试

interaction testing

复杂的接口 98101

complicated interfaces 98101

取决于记录器 8586

depending on loggers 8586

区分模拟和桩 8889

differentiating between mocks and stubs 8889

模拟对象 83103

mock objects 83103

功能型92

functional style 92

面向对象风格 94 , 96

in object-oriented style 94, 96

模拟和桩 84

mocks and stubs 84

部分模拟 101

partial mocks 101

接口,复杂,ISP 101

interfaces, complicated, ISP 101

接口隔离原则 131 , 133

interface segregation principle 131, 133

内部行为、模拟的过度规范 177179

internal behavior, overspecification with mocks 177179

控制反转 (IoC) 容器 77

Inversion of Control (IoC) containers 77

隔离测试 198

isolated tests 198

隔离设施 33

isolation facilities 33

隔离框架 104120

isolation frameworks 104120

的优点和陷阱

advantages and traps of

过度指定测试 119

overspecifying tests 119

无法读取测试代码 118

unreadable test code 118

验证错误的事情118

verifying wrong things 118

定义105

defining 105

松散与打字 105

loose vs. typed 105

动态伪造模块 106108

faking modules dynamically 106108

抽象掉直接依赖109

abstracting away direct dependencies 109

笑话 API 108

Jest API 108

功能动态模拟和桩 109110

functional dynamic mocks and stubs 109110

面向对象的动态模拟和桩 110

object-oriented dynamic mocks and stubs 110

使用松散类型框架 110112

using loosely typed framework 110112

动态桩行为 114117

stubbing behavior dynamically 114117

带有模拟和桩的面向对象示例 114116

object-oriented example with mock and stub 114116

与替代.js 116117

with substitute.js 116117

类型友好的框架 112113

type-friendly frameworks 112113

ISP(接口隔离原则)101

ISP (interface segregation principle) 101

isWebsiteAlive() 函数 129 , 133

isWebsiteAlive() function 129, 133

it() 函数 30 , 42 , 51

it() function 30, 42, 51

it.each() 函数 53 , 176

it.each() function 53, 176

it.only 关键字 172

it.only keyword 172

IUserDetails接口 170

IUserDetails interface 170

J

J

开玩笑 29

Jest 29

API 108

API of 108

避免手动模拟 247

avoiding manual mocks 247

开玩笑(续)

Jest (continued)

创建测试文件 3031

creating test files 3031

执行 3133

executing 3133

假定时器 139141

fake timers with 139141

242 忽略整个模块

ignoring whole modules with 242

安装 30

installing 30

库、断言、运行程序和报告程序 33

library, assert, runner, and reporter 33

猴子修补函数 241

monkey-patching functions 241

准备环境29

preparing environment 29

准备工作文件夹 2930

preparing working folder 2930

间谍 241

spies 241

验证密码() 函数 37

verifyPassword() function 37

Jest 语法风格 42

for Jest syntax flavors 42

开玩笑命令 30

jest command 30

jest.fn() 函数 112

jest.fn() function 112

jest.mock() 函数 134

jest.mock() function 134

jest.mock API,抽象出直接依赖项 109

jest.mock API, abstracting away direct dependencies 109

jest.mock([模块名称]) 函数 108

jest.mock([module name]) function 108

jest.restoreAllMocks 函数 242

jest.restoreAllMocks function 242

笑话快照 56

Jest snapshots 56

Jest 单元测试框架 36

Jest unit testing framework 36

玩笑--watch命令33

jest - -watch command 33

K

K

弗拉基米尔·霍里科夫 236

Khorikov, Vladimir 236

KPI(关键绩效指标) 208 , 220

KPIs (key performance indicators) 208, 220

L

L

滞后指标 220

lagging indicators 220

领先指标 221

leading indicators 221

分成组 221222

breaking up into groups 221222

旧代码 231237

legacy code 231237

集成测试,重构之前编写 236

integration tests, writing before refactoring 236

选择策略

selection strategies

简单优先策略234

easy-first strategy 234

硬优先策略,234 的优缺点

hard-first strategy, pros and cons of 234

从哪里开始添加测试 232233

where to start adding tests 232233

在重构之前编写集成测试 235

writing integration tests before refactoring 235

使用 CodeScene 调查生产代码 236

using CodeScene to investigate production code 236

loadHtmlAndGetUIElements方法144

loadHtmlAndGetUIElements method 144

loadHtml方法144

loadHtml method 144

洛达什图书馆 92

lodash library 92

记录变量 96

logged variable 96

记录器.调试 90

logger.debug 90

记录器信息 90

logger.info 90

记录器,取决于 8586

loggers, depending on 8586

松散的隔离框架 105

loose isolation frameworks 105

松散类型框架 110112

loosely typed frameworks 110112

仅低级测试反模式 202

low-level-only test antipattern 202

低级测试,断开高级测试和204

low-level tests, disconnected high-level and 204

LTS(长期支持)版本 29

LTS (long-term support) release 29

中号

M

魔法值 189190

magic values 189190

可维护性 89 , 165183

maintainability 89, 165183

避免过度规范 177179

avoiding overspecification 177179

因测试失败而强制进行的更改

changes forced by failing tests

其他测试的变化 169

changes in other tests 169

约束测试顺序 170173

constrained test order 170173

代码、精确输出和订购超规格 179183

of code, exact outputs and ordering overspecification 179183

测试次数

of tests

因测试失败而强制进行的更改 166

changes forced by failing tests 166

生产代码 API 166168的变化

changes in production code’s API 166168

测试不相关或与其他测试冲突 166

test is not relevant or conflicts with another test 166

重构增加173

refactoring to increase 173

避免设置方法 175176

avoiding setup methods 175176

避免测试私有或受保护的方法 175

avoiding testing private or protected methods 175

保持测试干燥 175

keeping tests DRY 175

使用参数化测试来删除重复 176177

using parameterized tests to remove duplication 176177

可维护测试 36

maintainable tests 36

维护窗口界面 115116

MaintenanceWindow interface 115116

makeFailingRule() 方法 50

makeFailingRule() method 50

makePassingRule() 方法 50

makePassingRule() method 50

makePerson() 函数 160

makePerson() function 160

makeSpecialApp() 工厂函数 173

makeSpecialApp() factory function 173

makeStubNetworkWithResult() 辅助函数 136

makeStubNetworkWithResult() helper function 136

makeVerifier() 函数 94

makeVerifier() function 94

杰拉德·梅萨罗斯 11 , 64 , 89

Meszaros, Gerard 11, 64, 89

方法,公开 174

methods, making public 174

指标、实验和218

metrics, experiments and 218

指标和 KPI 222

metrics and KPIs 222

模拟函数 98 , 246

mock functions 98, 246

模拟实现()函数 241242

mockImplementation() function 241242

模拟实现()方法 114

mockImplementation() method 114

mockImplementationOnce() 方法 114 , 247

mockImplementationOnce() method 114, 247

模拟日志变量 191

mockLog variable 191

模拟对象 12 , 83 , 98103 , 247

mock objects 12, 83, 98103, 247

隔离框架的优点 118

advantages of isolation frameworks 118

复杂的接口 98

complicated interfaces 98

直接使用 100 的缺点

downsides of using directly 100

98 – 99的示例

example of 9899

取决于记录器 8586

depending on loggers 8586

区分模拟和桩 8889

differentiating between mocks and stubs 8889

功能型92

functional style 92

面向对象风格 94

in object-oriented style 94

复杂接口的交互测试 99100

interaction testing with complicated interfaces 99100

模块化风格的模拟 89

modular-style mocks 89

以模块化注入方式重构生产代码 91

refactoring production code in modular injection style 91

84 概述

overview of 84

部分模拟 101

partial mocks 101

面向对象的部分模拟示例 102103

object-oriented partial mock example 102103

标准风格,引入参数重构 8788

standard style, introducing parameter refactoring 8788

模拟ReturnValue()方法114

mockReturnValue() method 114

模拟ReturnValueOnce()方法114

mockReturnValueOnce() method 114

模拟 63 , 84

mocks 63, 84

每个测试有多个模拟的优点 119

advantages of, having more than one mock per test 119

功能风格

functional style

柯里化 9293

currying 9293

高阶函数且不柯里化 93

higher-order functions and not currying 93

以面向对象的风格

in object-oriented style

重构注入的生产代码 95

refactoring production code for injection 95

通过接口注入重构生产代码 96

refactoring production code with interface injection 96

内部行为过度规范 177179

internal behavior overspecification with 177179

面向对象的设计示例 114116

object-oriented design example with 114116

面向对象的动态模拟和桩 110

object-oriented dynamic mocks and stubs 110

模块化适配器 133134

modular adapter 133134

模块化注射技术 7073

modular injection techniques 7073

模块化风格的模拟 89

modular-style mocks 89

产品代码 90 – 91的示例

example of production code 9091

模块化注入,示例 92

modular-style injection, example of 92

以模块化注入方式重构生产代码 91

refactoring production code in modular injection style 91

模块

modules

每次测试中的伪造行为 243249

faking behavior in each test 243249

用 Sinon.js 进行桩 247

stubbing with Sinon.js 247

使用 testdouble 进行桩 248249

stubbing with testdouble 248249

动态伪造 106108

faking dynamically 106108

抽象掉直接依赖109

abstracting away direct dependencies 109

猴子修补 238

monkey-patching 238

时刻.js 64

moment.js 64

猴子补丁 138 , 238

monkey-patching 138, 238

功能和模块

functions and modules

每次测试中伪造模块行为 248249

faking module behavior in each test 248249

带有普通 require.cache 的桩模块 244245

stubbing module with vanilla require.cache 244245

用 Sinon.js 进行桩 247

stubbing with Sinon.js 247

使用 testdouble 进行桩 248249

stubbing with testdouble 248249

可能的问题 239241

possible issues 239241

使用 mockImplementation() 进行间谍活动 241242

spyOn with mockImplementation() 241242

关于238的警告

warning about 238

N

网络适​​配器模块 132134

network-adapter module 132134

节点获取模块132

node-fetch module 132

节点.js 7

Node.js 7

节点包管理器 (NPM) 29

node package manager (NPM) 29

npm 命令 30

npm commands 30

NPM(节点包管理器) 4 , 29

NPM (node package manager) 4, 29

npm 运行 testw 命令 38

npm run testw command 38

npx 开玩笑命令 30

npx jest command 30

数字串 12

numbers string 12

O

面向对象的设计,带有模拟和桩的示例 114116

object-oriented design, example with mock and stub 114116

面向对象的动态模拟和桩 110

object-oriented dynamic mocks and stubs 110

类型友好的框架 112113

type-friendly frameworks 112113

使用松散类型框架 110112

using loosely typed framework 110112

面向对象注入技术 7481

object-oriented injection techniques 7481

构造函数注入 7476

constructor injection 7476

提取通用接口 7981

extracting common interface 7981

注入对象而不是函数 7679

injecting objects instead of functions 7679

概述 7981

overview 7981

面向对象风格,94 中的模拟

object-oriented style, mocks in 94

明显的价值 196

obvious values 196

洋葱架构 109

onion architecture 109

组织,将 unut 测试集成到 229

organization, integrating unut testing into 229

原始依赖关系对象 71

originalDependencies object 71

原始依赖变量 91

originalDependencies variable 91

输出依赖 62

outgoing dependencies 62

过度规范

overspecification

避免 177179

avoiding 177179

准确的输出和排序 179183

exact outputs and ordering 179183

P

参数注入 6667

parameter injection 6667

参数化测试

parameterized tests

重构为 5355

refactoring to 5355

使用 176177删除重复项

removing duplication with 176177

参数重构 8788

parameter refactoring 8788

部分应用程序,通过 70 进行依赖注入

partial application, dependency injection via 70

部分模拟 101

partial mocks 101

功能示例 101102

functional example 101102

面向对象的部分模拟示例 102103

object-oriented partial mock example 102103

通过结果 90

PASSED result 90

passVerify() 函数 94

passVerify() function 94

密码验证器0.spec.js 文件 37

password-verifier0.spec.js file 37

密码验证器1 46

PasswordVerifier1 46

密码验证器类 97

PasswordVerifier class 97

密码验证器工厂()函数 76

passwordVerifierFactory() function 76

密码验证器项目 37

Password Verifier project 37

修补功能和模块

patching functions and modules

避免 Jest 的手动模拟 247

avoiding Jest’s manual mocks 247

每次测试中伪造模块行为 243244

faking module behavior in each test 243244

使用 Jest 242 忽略整个模块

ignoring whole modules with Jest 242

人物对象160

person object 160

多态性96

polymorphism 96

端口和适配器架构 109

ports and adapters architecture 109

预配置验证器功能 94

preconfigured verifier function 94

私有和受保护的方法,避免测试 175

private and protected methods, avoiding testing 175

公开方法 174

making methods public 174

生产代码

production code

更改 API 166167

changing API of 166167

模块化风格的模拟,例如 9091

modular-style mocks, example of 9091

151 中的真正错误

real bug in 151

注入重构 95

refactoring for injection 95

模块化注入风格的重构 91

refactoring in modular injection style 91

使用接口注入进行重构 96

refactoring with interface injection 96

生产代码,使用 CodeScene 236 进行调查

production code, investigating with CodeScene 236

生产代码,重构 4345

production code, refactoring 4345

生产模块13

production module 13

进展,显而易见 219220

progress, making visible 219220

受保护的方法 174

protected methods 174

普莱斯,纳特 26

Pryce, Nat 26

Q

定性指标 222

qualitative metrics 222

查询 106

query 106

R

可读性 89

readability 89

魔法值和命名变量 189190

magic values and naming variables 189190

单元测试 187193

of unit tests 187193

将断言与动作分开 190

separating asserts from actions 190

可读测试 36

readable tests 36

原因字符串 41

reason string 41

.received() 函数 113 , 116

.received() function 113, 116

重构 24

refactoring 24

避免测试私有或受保护的方法 175

avoiding testing private or protected methods 175

保持测试干燥 175

keeping tests DRY 175

公开方法 174

making methods public 174

注射生产代码95

production code for injection 95

提高可维护性

to increase maintainability

避免设置方法 175176

avoiding setup methods 175176

避免测试私有或受保护的方法 174

avoiding testing private or protected methods 174

参数化测试 5355

to parameterized tests 5355

使用参数化测试来删除重复 176177

using parameterized tests to remove duplication 176177

235 之前编写集成测试

writing integration tests before 235

requireAndCall_findRecentlyRebooted() 函数 245

requireAndCall_findRecentlyRebooted() function 245

require.cache,带有普通版本的桩模块 244245

require.cache, stubbing module with vanilla 244245

require.cache机制243

require.cache mechanism 243

重置()函数 72

reset() function 72

ResetAllMocks() 函数 246

resetAllMocks() function 246

重置依赖性()函数 9192

resetDependencies() function 9192

重置模块()函数246

resetModules() function 246

RestoreAllMocks() 函数 246

restoreAllMocks() function 246

.returns() 函数 116

.returns() function 116

规则数组 166

rules array 166

规则验证功能 37

rules verification functions 37

S

S

安全绿区158

safe green zone 158

脚本项目 38

scripts item 38

接缝 71 , 232

seams 71, 232

使用 86 抽象依赖关系

abstracting dependencies using 86

选择策略 234

selection strategies 234

简单优先策略234

easy-first strategy 234

setTimeout 函数 138139

setTimeout function 138139

设置超时方法 138

setTimeout method 138

设置方法 191192

setup methods 191192

SimpleLogger 类 9697

SimpleLogger class 9697

sinon.js,使用桩模块 247

Sinon.js, stubbing modules with 247

较小的团队 215

smaller teams 215

SpecialApp 实施 170171

SpecialApp implementation 170171

间谍,开玩笑 241

spies, Jest 241

spyOn() 函数 241242

spyOn() function 241242

基于状态的测试 179

state-based test 179

无状态私有方法,使公共静态 175

stateless private methods, making public static 175

字符串比较 40

string comparisons 40

字符串匹配函数 110

stringMatching function 110

字符串,比较 40

strings, comparing 40

桩 88 , 108 , 241

stub 88, 108, 241

stubbing

动态 114117

dynamically 114117

模块 248249

modules 248249

与Sinon.js 247

with Sinon.js 247

与替代.js 116117

with substitute.js 116117

与 testdouble 248249

with testdouble 248249

桩 61 , 63 , 84

stubs 61, 63, 84

构造函数 7374

constructor functions 7374

设计方法 66

design approaches to 66

依赖、注入和控制 68

dependencies, injections, and control 68

通过参数注入消除时间 6667

stubbing out time with parameter injection 6667

功能注入技术 69

functional injection techniques 69

注入功能 6970

injecting functions 6970

模块化注射技术 7073

modular injection techniques 7073

面向对象注入技术 7481

object-oriented injection techniques 7481

构造函数注入 7476

constructor injection 7476

提取通用接口 7981

extracting common interface 7981

注入对象而不是函数 7679

injecting objects instead of functions 7679

概述 7981

overview 7981

84 概述

overview of 84

使用 6466的理由

reasons to use 6466

将数据库(或其他依赖项)替换为 16

replacing database (or another dependency) with 16

依赖类型 6264

types of dependencies 6264

被测主题、系统或套件 (SUT) 6

subject, system, or suite under test (SUT) 6

Substitute.for<T>() 函数 116

Substitute.for<T>() function 116

替代.js 116117

substitute.js 116117

子团队,创建 216

subteams, creating 216

sum() 函数 12

sum() function 12

S单元36

SUnit 36

SUT(被测主题、系统或套件)6

SUT (subject, system, or suite under test) 6

时间

T

胶带框架36

tape framework 36

TAP(测试任何协议)36

TAP (Test Anything Protocol) 36

TDD(测试驱动开发)5 个带参数注入的stubbing out 时间, 22 个带参数注入的stubbing out 时间, 152229

TDD (test-driven development) 5stubbing out time with parameter injection, 22stubbing out time with parameter injection, 152229

25 的核心技能

core skills for 25

陷阱,但不能替代良好的单元测试 24

pitfalls of, not a substitute for good unit tests 24

拆卸方法 191192

teardown methods 191192

test()函数,概述52

test() function, overview of 52

testableLog变量101

testableLog variable 101

测试类别 5657

test categories 5657

测试与另一个测试冲突 152153

test conflicts with another test 152153

测试双重笑话 249

testdouble-jest 249

测试双打 64, 243

test doubles 64, 243

桩模块 248249

stubbing modules with 248249

测试驱动开发 (TDD) 5 , 25 , 152

test-driven development (TDD) 5, 25, 152

测试.each 函数 53 , 176

test.each function 53, 176

测试失败

test failure

152 的理由

reasons for 152

测试与另一个测试冲突 152153

test conflicts with another test 152153

由于功能更改,测试已过时 152

test out of date due to change in functionality 152

测试可行性表 232

test-feasibility table 232

测试优先开发 22

test-first development 22

测试片状性 82

test flakiness 82

测试功能30

test function 30

测试

testing

测试失败的原因

reasons tests fail

有缺陷的测试给出错误的失败 151

buggy test gives false failure 151

测试与另一个测试冲突 152

test conflicts with another test 152

在通过测试时嗅到错误的信任感 156

smelling false sense of trust in passing tests 156

值得信赖的测试 164

trustworthy tests 164

测试策略 194212

testing strategy 194212

常见的测试类型和级别 195

common test types and levels 195

API 测试 198

API tests 198

判断测试的标准 196

criteria for judging tests 196

E2E/UI隔离测试 198

E2E/UI isolated tests 198

E2E/UI系统测试 199

E2E/UI system tests 199

集成测试 197

integration tests 197

单元测试和组件测试 196

unit tests and component tests 196

开发、使用测试配方 205

developing, using test recipes 205

仅低级测试反模式 202

low-level-only test antipattern 202

管理交付管道 208

managing delivery pipelines 208

交付与发现管道 208

delivery vs. discovery pipelines 208

测试层并行化 210211

test layer parallelization 210211

测试级反模式

test-level antipatterns

断开的低级和高级测试 203

disconnected low-level and high-level tests 203

仅端到端反模式 199 , 201

end-to-end-only antipattern 199, 201

测试食谱

test recipes

207 – 208的规则

rules for 207208

书写和使用207

writing and using 207

测试层并行化 210211

test layer parallelization 210211

测试库33

test library 33

测试可维护性,受限测试顺序 170173

test maintainability, constrained test order 170173

测试方法13

test method 13

testPathPattern 命令行标志 56

testPathPattern command-line flag 56

--testPathPattern 标志 58

- -testPathPattern flag 58

测试食谱

test recipes

作为测试策略 205

as testing strategy 205

207 – 208的规则

rules for 207208

写作205

writing 205

书写和使用207

writing and using 207

测试正则表达式配置 56

testRegex configuration 56

测试记者33

test reporter 33

测试复习,用作教学工具 216

test reviews, using as teaching tools 216

测试跑者 33

test runner 33

测试、判断标准 196

tests, criteria for judging 196

__tests__ 文件夹 3738

__tests__ folder 3738

测试语法 42

test syntax 42

then() 回调 124

then() callback 124

第三方测试 179

third-party test 179

时间,添加到流程 226227

time, added to process 226227

TimeProvider接口类型 79

TimeProviderInterface type 79

定时器 138

timers 138

开玩笑 139141

faking with Jest 139141

用猴子补丁清除 138139

stubbing out with monkey-patching 138139

.toContain('假原因') 函数 38

.toContain('fake reason') function 38

.to包含匹配器 40

.toContain matcher 40

toMatchInlineSnapshot() 方法 56

toMatchInlineSnapshot() method 56

.toMatch 匹配器 40

.toMatch matcher 40

.toMatch(/string/) 函数 38

.toMatch(/string/) function 38

自上而下的方法217

top-down approach 217

TotalSoFar() 函数 9

totalSoFar() function 9

抛出错误方法 56

toThrowError method 56

转译器 96

transpiler 96

趋势线 222

trend lines 222

真正的失败166

true failures 166

信任 89

trust 89

值得信赖的测试 36 , 149164

trustworthy tests 36, 149164

避免单元测试中的逻辑 153 , 155156

avoiding logic in unit tests 153, 155156

创建动态预期值 153155

creating dynamic expected values 153155

更有逻辑性156

even more logic 156

有缺陷的测试

buggy tests

判断测试的标准 196

criteria for judging tests 196

找到 151 后该怎么办

what to do once you’ve found 151

处理不稳定的测试 163

dealing with flaky tests 163

失败测试 151

failed tests 151

片状测试 161 , 163

flaky tests 161, 163

测试失败的原因 150152

reasons for test failure 150152

有缺陷的测试给出错误的失败 151

buggy test gives false failure 151

片状测试 153

flaky tests 153

由于功能更改而过时 152

out of date due to change in functionality 152

生产代码 151 中的真正错误

real bug in production code 151

测试与另一个测试冲突 152

test conflicts with another test 152

在通过测试时嗅到错误的信任感 156

smelling false sense of trust in passing tests 156

混合单元测试和片状集成测试 158

mixing unit tests and flaky integration tests 158

测试多个退出点 158160

testing multiple exit points 158160

不断言任何内容的测试 157

tests that don’t assert anything 157

不断变化的测试 160

tests that keep changing 160

测试与另一个测试冲突 153

test conflicts with another test 153

类型友好的框架 112113

type-friendly frameworks 112113

U

U

UI(用户界面)测试 198199

UI (user interface) tests 198199

工作单元 6 , 241

unit of work 6, 241

单元测试 15 , 21

unit test 15, 21

单元测试 327

unit testing 327

异步代码 121146

asynchronous code 121146

基础5

basics of 5

良好单元测试的特征 1516

characteristics of good unit tests 1516

从头开始创建单元测试 1215

creating unit tests from scratch 1215

定义 5

defining 5

不同的退出点,不同的技术 12

different exit points, different techniques 12

教育同事为棘手问题做好准备 214

educating colleagues about, being prepared for tough questions 214

入口点和出口点 610

entry points and exit points 610

退出点类型 11

exit point types 11

第一个单元测试 2858

first unit test 2858

框架,3436的优点

frameworks, advantages of 3436

融入组织 213230

integrating into organization 213230

临时实施和第一印象 223

ad hoc implementations and first impressions 223

瞄准特定目标、指标和 KPI 220

aiming for specific goals, metrics, and KPIs 220

未经测试的代码 228

code without tests 228

作为开门器的实验 217

experiments as door openers 217

获得外部冠军218

getting outside champion 218

游击实施217

guerrilla implementation 217

影响因素224

influence factors 224

缺乏政治支持 223

lack of political support 223

如果调试器显示代码有效,则需要进行测试 229

need for tests if debugger shows that code works 229

成为变革推动者的步骤 214216

steps to becoming agent of change 214216

TDD(测试驱动开发)25 , 226230

TDD (test-driven development) 25, 226230

添加到过程 226 – 227的时间

time added to process 226227

棘手的问题和答案 226229

tough questions and answers 226229

证明单元测试有帮助228

proof that unit testing helps 228

面临风险的 QA 工作 227

QA jobs at risk 227

失败的方法223

ways to fail 223

缺乏驱动力223

lack of driving force 223

缺乏团队支持 224

lack of team support 224

成功的方法216

ways to succeed 216

令人信服的管理 217

convincing management 217

意识到会有障碍222

realize that there will be hurdles 222

使用模拟对象进行交互测试 83103

interaction testing using mock objects 83103

旧代码 237

legacy code 237

选择策略 234

selection strategies 234

从哪里开始添加测试 232233

where to start adding tests 232233

在重构之前编写集成测试 235236

writing integration tests before refactoring 235236

可维护性 183

maintainability 183

桩,使用原因 6466

stubs, reasons to use 6466

单元测试异步代码,提取工作单元的示例 127129

unit testing asynchronous code, example of extracting unit of work 127129

单元测试原则、实践和模式(Khorikov) 236

Unit Testing Principles, Practices, and Patterns (Khorikov) 236

单元测试 28

unit tests 28

153 避免逻辑

avoiding logic in 153

创建动态预期值 153155

creating dynamic expected values 153155

其他形式的逻辑 155156

other forms of logic 155156

避免过度规范 177

avoiding overspecification 177

15 的特征

characteristics of 15

16 人清单

checklist for 16

通过线性同步测试模拟异步处理 16

emulating asynchronous processing with linear, synchronous tests 16

概述 15

overview 15

用桩 16 替换数据库(或其他依赖项)

replacing database (or another dependency) with stub 16

良好单元测试的特征 15

characteristics of good unit tests 15

创造

creating

beforeEach() 函数 4547

beforeEach() function 4547

从头开始 1215

from scratch 1215

使用 test() 函数 52

using test() function 52

异常,检查预期抛出的错误 5556

exceptions, checking for expected thrown errors 5556

每次测试中伪造模块行为 244245

faking module behavior in each test 244245

第一个单元测试 2858

first unit test 2858

工厂方法路线 4950

factory method route 4950

重构 beforeEach() 函数 4749

refactoring to beforeEach() function 4749

用工厂方法完全替换 beforeEach() 5051

replacing beforeEach() completely with factory methods 5051

验证密码() 函数 37

verifyPassword() function 37

笑话

Jest

创建测试文件 3031

creating test files 3031

第一次测试,准备工作文件夹 2930

first test with, preparing working folder 2930

库、断言、运行程序和报告程序 33

library, assert, runner, and reporter 33

命名 188189

naming 188189

196 概述

overview of 196

参数化测试,重构为 5355

parameterized tests, refactoring to 5355

密码验证器项目 37

Password Verifier project 37

可读性 187193

readability 187193

魔法值和命名变量 189190

magic values and naming variables 189190

将断言与动作分开 190

separating asserts from actions 190

设置和拆除 191192

setting up and tearing down 191192

重构生产代码 4345

refactoring production code 4345

设置测试类别 5657

setting test categories 5657

单元测试框架 36

unit testing frameworks 36

无法读取测试代码 118

unreadable test code 118

用户缓存对象 170

UserCache object 170

USE(单位、场景、期望)命名 39

USE (unit, scenario, expectation) naming 39

V

V

基于价值的测试 179

value-based test 179

普通 require.cache 244245

vanilla require.cache 244245

验证 87

verification 87

验证者 78

verifier 78

验证者变量 46

verifier variable 46

verify() 函数 55 良好单元测试的特征 15, 95 良好单元测试的特征 15, 178180

verify() function 55characteristics of good unit tests 15, 95characteristics of good unit tests 15, 178180

verifyPassword() 函数 45 良好单元测试的特征 15, 86 良好单元测试的特征 15, 90

verifyPassword() function 45characteristics of good unit tests 15, 86characteristics of good unit tests 15, 90

安排-行动-断言模式 38

Arrange-Act-Assert pattern 38

描述()函数 4041

describe() function 4041

第一次测试37

first test for 37

笑话语法风格 42

Jest syntax flavors 42

42 的玩笑测试

Jest test for 42

重构生产代码 4345

refactoring production code 4345

结构可以暗示上下文 4142

structure can imply context 4142

测试字符串,比较 40

testing strings, comparing 40

测试测试39

testing test 39

验证密码(规则)函数 37

verifyPassword(rules) function 37

W

网站验证器类 136137

WebsiteVerifier class 136137

网站验证程序示例 135136

website-verifier example 135136

书面课98

written class 98

X

X

xUnit 框架 36

xUnit frameworks 36

xUnit 测试模式和命名事物 64

xUnit test patterns and naming things 64

xUnit 测试模式:重构测试代码(Meszaros) 11 , 64 , 89

xUnit Test Patterns: Refactoring Test Code (Meszaros) 11, 64, 89